Skip to content

Commit

Permalink
Backend functions for sending
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Nov 3, 2024
1 parent e4f40bc commit 74b2dfa
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 12 deletions.
3 changes: 2 additions & 1 deletion backend/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -211,5 +212,5 @@ export default async () => {
}
numNewKeys && console.info(`[chelonia.db] Preloaded ${numNewKeys} new entries`)
}
await initZkpp()
await Promise.all([initVapid(), initZkpp()])
}
67 changes: 56 additions & 11 deletions backend/push.js
Original file line number Diff line number Diff line change
@@ -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<string> => {
const textEncoder = new TextEncoder()
Expand Down Expand Up @@ -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
Expand All @@ -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<void> => {
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)
}
}
}
97 changes: 97 additions & 0 deletions backend/vapid.js
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' }

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<string> => {
const jwt = await generateJwt(endpoint)
return `vapid t=${jwt}, k=${vapidPublicKey}`
}

0 comments on commit 74b2dfa

Please sign in to comment.