Skip to content

Instantly share code, notes, and snippets.

@EECOLOR
Last active February 24, 2025 00:13
Show Gist options
  • Save EECOLOR/46bfce9e1d675bf043aebb620d56dee7 to your computer and use it in GitHub Desktop.
Save EECOLOR/46bfce9e1d675bf043aebb620d56dee7 to your computer and use it in GitHub Desktop.
Web push without external libraries
#!/usr/bin/env node
import { generateKeyPairSync } from 'node:crypto'
import { statSync, writeFileSync } from 'node:fs'
ensureKeyFile('./config/vapid_keys.json')
function ensureKeyFile(keyFile) {
if (statSync(keyFile, { throwIfNoEntry: false })?.isFile())
return
writeFileSync(keyFile, JSON.stringify(generateVapidKeys(), null, 2))
}
function generateVapidKeys() {
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const publicKeyBuffer = Buffer.from(
publicKey.export({ type: 'spki', format: 'der' }).subarray(26)
)
return {
publicKeyBase64Url: publicKeyBuffer.toString('base64url'),
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
}
}
import { clientConfig } from '#ui/islands/clientConfig.js'
if (!navigator.serviceWorker)
throw new Error('Browser does not support registering service worker')
const serviceWorkerPath = new URL('./web-workers/service-worker.js', import.meta.url).href
const registration = await navigator.serviceWorker.register(serviceWorkerPath, { type: 'module' })
console.log('Service Worker registered')
await registration.update()
console.log('Service Worker updated')
await subscribeToNotifications()
async function subscribeToNotifications() {
const permission = await Notification.requestPermission()
if (permission !== 'granted')
return console.warn('No permission for notifications')
await refreshSubscription(registration.pushManager)
}
async function refreshSubscription(pushManager) {
const existingSubscription = await pushManager.getSubscription()
if (existingSubscription)
return console.log(existingSubscription)
console.log("Resubscribing user...")
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: clientConfig.vapid.publicKeyBase64Url,
})
console.log(subscription)
// const response = await fetch("/api/web-push-subscriptions", {
// method: "POST",
// body: JSON.stringify(subscription),
// headers: { "Content-Type": "application/json" },
// })
// console.log(response.ok ? 'Subscription created' : `Subscription failed:\nStatus: ${response.status}\nBody: ${await response.text()}`)
}
#!/usr/bin/env node
import crypto from 'node:crypto'
import config from '#config'
import { Buffer } from 'node:buffer'
await sendPushNotification({
subscription: config.browserPushSubscription,
payload: JSON.stringify({
title: 'Hello, this is a test',
body: 'This is a test body'
}),
vapid: {
publicKey: config.vapidKeys.publicKeyBase64Url,
privateKey: config.vapidKeys.privateKeyPem,
subject: `mailto:${config.email}`,
}
})
/**
* @param {{
* subscription: {
* endpoint: string
* keys: {
* p256dh: string
* auth: string
* }
* },
* vapid: {
* publicKey: string
* privateKey: string
* subject: string
* },
* payload: string
* }} props
* @returns
*/
async function sendPushNotification({
subscription,
payload,
vapid,
}) {
const endpoint = subscription.endpoint
const headers = createWebPushHeaders(endpoint, vapid)
const encryptedPayload = encryptPayload(payload, subscription.keys)
const response = await fetch(endpoint, { method: 'POST', headers, body: encryptedPayload })
if (!response.ok)
throw new Error(`Push service responded with ${response.status}: ${response.text()}`)
}
/**
* @param {string} endpoint
* @param {{ publicKey: string, privateKey: string, subject: string }} vapid
*/
function createWebPushHeaders(endpoint, vapid) {
const audience = new URL(endpoint).origin
const jwt = createVapidJwt(audience, vapid.subject, vapid.privateKey)
return {
'TTL': '86400',
'Content-Encoding': 'aes128gcm',
'Authorization': `vapid t=${jwt}, k=${vapid.publicKey}`,
}
}
function createVapidJwt(audience, subject, privateKey) {
const header = { typ: 'JWT', alg: 'ES256' }
const body = {
aud: audience,
exp: Math.floor(Date.now() / 1000) + (12 * 60 * 60),
sub: subject
}
const encodedHeader = encodeJson(header)
const encodedBody = encodeJson(body)
const unsignedToken = `${encodedHeader}.${encodedBody}`
const signature = crypto.createSign('SHA256')
.update(unsignedToken)
.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' })
.toString('base64url')
return `${unsignedToken}.${signature}`
}
function encodeJson(json) {
return Buffer.from(JSON.stringify(json)).toString('base64url')
}
/**
* @param {string} payload
* @param {{ p256dh: string, auth: string }} subscriptionKeys
*/
function encryptPayload(payload, subscriptionKeys) {
const client = getClientInfo(subscriptionKeys)
const server = getServerInfo(client)
const salt = crypto.randomBytes(16)
const { encryptionKey, nonce } = createEncryptionKeyAndNonce(client, server, salt)
const paddedPayload = Buffer.concat([Buffer.from(payload), Buffer.from([0x02])]) // 0x02 is the padding delimiter
const recordSize = Buffer.alloc(4)
recordSize.writeUInt32BE(paddedPayload.length + 16 + 16, 0) // padded payload size + tag (16) + buffer (16)
const header = Buffer.concat([
salt, // 16 bytes
recordSize, // 4 bytes
Buffer.from([0x41]), // 1 byte with value 65 (server public key length)
server.publicKey // 65 bytes with uncompressed public key
])
const cipher = crypto.createCipheriv('aes-128-gcm', encryptionKey, nonce)
const encryptedData = Buffer.concat([cipher.update(paddedPayload), cipher.final()])
const tag = cipher.getAuthTag() // 16-byte authentication tag
return Buffer.concat([header, encryptedData, tag])
}
/** @typedef {ReturnType<typeof getClientInfo>} ClientInfo */
/** @param {{ p256dh: string, auth: string }} subscriptionKeys */
function getClientInfo(subscriptionKeys) {
return {
publicKey: Buffer.from(subscriptionKeys.p256dh, 'base64url'),
authSecret: Buffer.from(subscriptionKeys.auth, 'base64url'),
}
}
/** @typedef {ReturnType<typeof getServerInfo>} ServerInfo */
/** @param {ClientInfo} client */
function getServerInfo(client) {
// Generate server's ECDH key pair (P-256 curve)
const serverECDH = crypto.createECDH('prime256v1')
serverECDH.generateKeys()
return {
publicKey: serverECDH.getPublicKey(),
sharedSecret: serverECDH.computeSecret(client.publicKey),
}
}
/** @param {ClientInfo} client @param {ServerInfo} server @param {Buffer} salt */
function createEncryptionKeyAndNonce(client, server, salt) {
const pseudoRandomKey = createPseudoRandomKey(client, server, salt)
return {
encryptionKey: hkdf(pseudoRandomKey, contentEncoding('aes128gcm'), 16), // AES-128-GCM needs 16 bytes
nonce: hkdf(pseudoRandomKey, contentEncoding('nonce'), 12), // AES-128-GCM nonce is 12 bytes
}
}
/** @param {ClientInfo} client @param {ServerInfo} server */
function createPseudoRandomKey(client, server, salt) {
const hashedScharedSecret = crypto.createHmac('sha256', client.authSecret)
.update(server.sharedSecret)
.digest()
const pseudoRandomKeyInfo = Buffer.concat([
Buffer.from('WebPush: info\0', 'ascii'),
client.publicKey,
server.publicKey,
])
const pseudoRandomKey = hkdf(hashedScharedSecret, pseudoRandomKeyInfo, 32)
const hashedPseudoRandomKey = crypto.createHmac('sha256', salt)
.update(pseudoRandomKey)
.digest()
return hashedPseudoRandomKey
}
function contentEncoding(encoding) {
return Buffer.from(`Content-Encoding: ${encoding}\0`, 'ascii')
}
/** @param {Buffer} key @param {Buffer} info @param {number} length */
function hkdf(key, info, length) {
return crypto.createHmac('sha256', key)
.update(info)
.update(Buffer.from([0x01]))
.digest()
.subarray(0, length)
}
/** @type {ServiceWorkerGlobalScope} */
const sw = /** @type {any} */ (self)
sw.addEventListener('install', event => {
console.log('Service Worker installed')
})
sw.addEventListener('activate', event => {
console.log('Service worker activated')
event.waitUntil(sw.clients.claim())
})
sw.addEventListener('push', event => {
console.log('Push received:', event)
const data = event.data.json()
event.waitUntil(
sw.registration.showNotification(data.title, { body: data.body })
)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment