From 74b2dfa934cc1b396de829d72edfdee73deb751f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sun, 3 Nov 2024 20:08:32 +0000 Subject: [PATCH] Backend functions for sending --- backend/database.js | 3 +- backend/push.js | 67 ++++++++++++++++++++++++++----- backend/vapid.js | 97 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 backend/vapid.js diff --git a/backend/database.js b/backend/database.js index 19740e08e4..f4766e3968 100644 --- a/backend/database.js +++ b/backend/database.js @@ -9,6 +9,7 @@ import path from 'node:path' import '@sbp/okturtles.data' import { checkKey, parsePrefixableKey, prefixHandlers } from '~/shared/domains/chelonia/db.js' import LRU from 'lru-cache' +import { initVapid } from './vapid.js' import { initZkpp } from './zkppSalt.js' const Boom = require('@hapi/boom') @@ -211,5 +212,5 @@ export default async () => { } numNewKeys && console.info(`[chelonia.db] Preloaded ${numNewKeys} new entries`) } - await initZkpp() + await Promise.all([initVapid(), initZkpp()]) } diff --git a/backend/push.js b/backend/push.js index 6078acf210..1639e22a83 100644 --- a/backend/push.js +++ b/backend/push.js @@ -1,20 +1,13 @@ +import { aes128gcm } from '@exact-realty/rfc8188/encodings' +import encrypt from '@exact-realty/rfc8188/encrypt' // $FlowFixMe[missing-export] import { webcrypto as crypto } from 'crypto' // Needed for Node 18 and under import rfc8291Ikm from './rfc8291Ikm.js' +import { getVapidPublicKey, vapidAuthorization } from './vapid.js' // const pushController = require('web-push') -const giConfig = require('../giconf.json') const { PUSH_SERVER_ACTION_TYPE, REQUEST_TYPE, createMessage } = require('../shared/pubsub.js') -// NOTE: VAPID public/private keys can be generated via 'npx web-push generate-vapid-keys' command. -const publicKey = process.env.VAPID_PUBLIC_KEY || giConfig.VAPID_PUBLIC_KEY -// const privateKey = process.env.VAPID_PRIVATE_KEY || giConfig.VAPID_PRIVATE_KEY -/* pushController.setVapidDetails( - process.env.VAPID_EMAIL || giConfig.VAPID_EMAIL, - publicKey, - privateKey -) */ - // Generate an UUID from a `PushSubscription' export const getSubscriptionId = async (subscriptionInfo: Object): Promise => { const textEncoder = new TextEncoder() @@ -118,7 +111,7 @@ export const subscriptionInfoWrapper = (subcriptionId: string, subscriptionInfo: export const pushServerActionhandlers: any = { [PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY] () { const socket = this - socket.send(createMessage(REQUEST_TYPE.PUSH_ACTION, { type: PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY, data: publicKey })) + socket.send(createMessage(REQUEST_TYPE.PUSH_ACTION, { type: PUSH_SERVER_ACTION_TYPE.SEND_PUBLIC_KEY, data: getVapidPublicKey() })) }, async [PUSH_SERVER_ACTION_TYPE.STORE_SUBSCRIPTION] (payload) { const socket = this @@ -134,3 +127,55 @@ export const pushServerActionhandlers: any = { delete socket.server.pushSubscriptions[subscriptionId] } } + +// TODO: Implement +const deleteClient = (_) => {} + +const encryptPayload = async (subcription: Object, data: any) => { + const readableStream = new Response(data).body + const [asPublic, IKM] = await subcription.encryptionKeys + + return encrypt(aes128gcm, readableStream, 32768, asPublic, IKM) +} + +export const postEvent = async (subscription: Object, event: any): Promise => { + const authorization = await vapidAuthorization(subscription.endpoint) + const body = await encryptPayload(subscription, JSON.stringify(event)).then(async (bodyStream) => { + const chunks = [] + const reader = bodyStream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(new Uint8Array(value)) + } + return Buffer.concat(chunks) + }) + + const req = await fetch(subscription.endpoint, { + method: 'POST', + headers: [ + ['authorization', authorization], + ['content-encoding', 'aes128gcm'], + [ + 'content-type', + 'application/octet-stream' + ], + // ["push-receipt", ""], + ['ttl', '60'] + ], + body + }) + + if (!req.ok) { + // If the response was 401 (Unauthorized), 404 (Not found) or 410 (Gone), + // it likely means that the subscription no longer exists. + if ([401, 404, 410].includes(req.status)) { + console.warn( + new Date().toISOString(), + 'Removing subscription', + subscription.id + ) + deleteClient(subscription.id) + } + } +} diff --git a/backend/vapid.js b/backend/vapid.js new file mode 100644 index 0000000000..79e86b22e6 --- /dev/null +++ b/backend/vapid.js @@ -0,0 +1,97 @@ +import sbp from '@sbp/sbp' +// $FlowFixMe[missing-export] +import { webcrypto as crypto } from 'crypto' // Needed for Node 18 and under + +let vapidPublicKey: string +let vapidPrivateKey: Object + +// TODO: Load from configuration +const vapid = { VAPID_EMAIL: 'test@example.com' } + +export const initVapid = async () => { + const vapidKeyPair = await sbp('chelonia/db/get', '_private_immutable_vapid_key').then(async (vapidKeyPair: string): Promise<[Object, string]> => { + if (!vapidKeyPair) { + // Generate a new ECDSA key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256' // Use P-256 curve + }, + true, // Whether the key is extractable + ['sign', 'verify'] // Usages + ) + + // Export the private key + const serializedKeyPair = await Promise.all([ + crypto.subtle.exportKey('jwk', keyPair.privateKey), + crypto.subtle.exportKey('raw', keyPair.publicKey).then((key) => + // $FlowFixMe[incompatible-call] + Buffer.from(key).toString('base64url') + ) + ]) + + return sbp('chelonia/db/set', '_private_immutable_vapid_key', JSON.stringify(serializedKeyPair)).then(() => { + return [keyPair.privateKey, serializedKeyPair[1]] + }) + } + + const serializedKeyPair = JSON.parse(vapidKeyPair) + return [ + await crypto.subtle.importKey( + 'jwk', + serializedKeyPair[0], + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ), + serializedKeyPair[1] + ] + }) + + vapidPrivateKey = vapidKeyPair[0] + vapidPublicKey = vapidKeyPair[1] +} + +const generateJwt = async (endpoint) => { + const privateKey = await crypto.subtle.importKey( + 'jwk', + vapidPrivateKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ) + + const now = Math.round(Date.now() / 1e3) + + const audience = endpoint.origin + + const header = Buffer.from(JSON.stringify( + Object.fromEntries([['typ', 'JWT'], ['alg', 'ES256']]) + )).toString('base64url') + const body = Buffer.from(JSON.stringify( + Object.fromEntries([ + ['aud', audience], + ['exp', now + 60], + ['iat', now], + ['nbf', now - 60], + ['sub', vapid.VAPID_EMAIL] + ]) + )).toString('base64url') + + const signature = Buffer.from( + await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + privateKey, + Buffer.from([header, body].join('.')) + ) + ).toString('base64url') + + return [header, body, signature].join('.') +} + +export const getVapidPublicKey = (): string => vapidPublicKey + +export const vapidAuthorization = async (endpoint: string): Promise => { + const jwt = await generateJwt(endpoint) + return `vapid t=${jwt}, k=${vapidPublicKey}` +}