| 1 | /** |
| 2 | * Web Push, with nothing but WebCrypto: VAPID (RFC 8292), an ES256 JWT |
| 3 | * that tells the push service who is sending, and the message encrypted |
| 4 | * for the browser that subscribed (RFC 8291, the `aes128gcm` content |
| 5 | * coding of RFC 8188). One record, padded to nothing. |
| 6 | * |
| 7 | * Keys travel base64url-encoded as browsers give them: a public key is the |
| 8 | * 65-byte uncompressed P-256 point, a private key its 32-byte scalar. |
| 9 | */ |
| 10 | |
| 11 | const enc = new TextEncoder(); |
| 12 | |
| 13 | export function b64url(bytes: Uint8Array): string { |
| 14 | let text = ""; |
| 15 | for (const byte of bytes) text += String.fromCharCode(byte); |
| 16 | return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 17 | } |
| 18 | |
| 19 | export function fromB64url(text: string): Uint8Array { |
| 20 | const normal = text.replace(/-/g, "+").replace(/_/g, "/"); |
| 21 | const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); |
| 22 | const raw = atob(padded); |
| 23 | const out = new Uint8Array(raw.length); |
| 24 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 25 | return out; |
| 26 | } |
| 27 | |
| 28 | /** A copy as a plain ArrayBuffer, which every WebCrypto call takes. */ |
| 29 | function buf(bytes: Uint8Array): ArrayBuffer { |
| 30 | return bytes.slice().buffer as ArrayBuffer; |
| 31 | } |
| 32 | |
| 33 | function concat(...parts: Uint8Array[]): Uint8Array { |
| 34 | const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); |
| 35 | let at = 0; |
| 36 | for (const part of parts) { |
| 37 | out.set(part, at); |
| 38 | at += part.length; |
| 39 | } |
| 40 | return out; |
| 41 | } |
| 42 | |
| 43 | /** A P-256 private key from its scalar and public point, for `usage`. */ |
| 44 | export async function importPrivateKey(privateB64: string, publicB64: string, usage: "sign" | "ecdh"): Promise<CryptoKey> { |
| 45 | const point = fromB64url(publicB64); |
| 46 | if (point.length !== 65 || point[0] !== 4) throw new Error("A P-256 public key is 65 bytes, starting 0x04."); |
| 47 | const jwk: JsonWebKey = { |
| 48 | kty: "EC", |
| 49 | crv: "P-256", |
| 50 | d: privateB64, |
| 51 | x: b64url(point.slice(1, 33)), |
| 52 | y: b64url(point.slice(33, 65)), |
| 53 | ext: true, |
| 54 | }; |
| 55 | return usage === "sign" |
| 56 | ? crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]) |
| 57 | : crypto.subtle.importKey("jwk", jwk, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); |
| 58 | } |
| 59 | |
| 60 | export type Vapid = { publicKey: string; privateKey: string; subject: string }; |
| 61 | |
| 62 | /** |
| 63 | * The VAPID JWT for a push service: `aud` its origin, `exp` at most a day |
| 64 | * off (12 hours here), `sub` how to reach the sender. Signed ES256, whose |
| 65 | * signature is the raw r‖s WebCrypto gives. |
| 66 | */ |
| 67 | export async function vapidJwt(endpoint: string, vapid: Vapid, now = Date.now()): Promise<string> { |
| 68 | const header = b64url(enc.encode(JSON.stringify({ typ: "JWT", alg: "ES256" }))); |
| 69 | const claims = b64url( |
| 70 | enc.encode(JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 3600, sub: vapid.subject })), |
| 71 | ); |
| 72 | const key = await importPrivateKey(vapid.privateKey, vapid.publicKey, "sign"); |
| 73 | const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, enc.encode(`${header}.${claims}`)); |
| 74 | return `${header}.${claims}.${b64url(new Uint8Array(signature))}`; |
| 75 | } |
| 76 | |
| 77 | /** The `Authorization` header a push carries. */ |
| 78 | export async function vapidAuthorization(endpoint: string, vapid: Vapid, now = Date.now()): Promise<string> { |
| 79 | return `vapid t=${await vapidJwt(endpoint, vapid, now)}, k=${vapid.publicKey}`; |
| 80 | } |
| 81 | |
| 82 | async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, bytes: number): Promise<Uint8Array> { |
| 83 | const key = await crypto.subtle.importKey("raw", buf(ikm), "HKDF", false, ["deriveBits"]); |
| 84 | const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: buf(salt), info: buf(info) }, key, bytes * 8); |
| 85 | return new Uint8Array(bits); |
| 86 | } |
| 87 | |
| 88 | /** The content key and nonce of RFC 8291 §3.4, from both sides' keys and the auth secret. */ |
| 89 | async function keys(input: { ecdhSecret: Uint8Array; auth: Uint8Array; uaPublic: Uint8Array; asPublic: Uint8Array; salt: Uint8Array }) { |
| 90 | const keyInfo = concat(enc.encode("WebPush: info\0"), input.uaPublic, input.asPublic); |
| 91 | const ikm = await hkdf(input.auth, input.ecdhSecret, keyInfo, 32); |
| 92 | const cek = await hkdf(input.salt, ikm, enc.encode("Content-Encoding: aes128gcm\0"), 16); |
| 93 | const nonce = await hkdf(input.salt, ikm, enc.encode("Content-Encoding: nonce\0"), 12); |
| 94 | return { cek, nonce }; |
| 95 | } |
| 96 | |
| 97 | /** The record size written in the header: one record holds the whole message. */ |
| 98 | const RECORD_SIZE = 4096; |
| 99 | |
| 100 | /** Sender's own key pair and salt, given only by tests to reproduce a known vector. */ |
| 101 | export type Fixed = { asPrivate: string; asPublic: string; salt: Uint8Array }; |
| 102 | |
| 103 | /** |
| 104 | * `plaintext` encrypted for a subscription's `p256dh` and `auth`, as the |
| 105 | * body of a push: salt (16) ‖ record size (4) ‖ key id length (1) ‖ the |
| 106 | * sender's public key (65) ‖ the one record. |
| 107 | */ |
| 108 | export async function encrypt(plaintext: Uint8Array, subscription: { p256dh: string; auth: string }, fixed?: Fixed): Promise<Uint8Array> { |
| 109 | if (plaintext.length > RECORD_SIZE - 17 - 86) throw new Error("A push holds at most about 3,990 bytes."); |
| 110 | const uaPublic = fromB64url(subscription.p256dh); |
| 111 | const auth = fromB64url(subscription.auth); |
| 112 | let asPrivateKey: CryptoKey; |
| 113 | let asPublic: Uint8Array; |
| 114 | if (fixed) { |
| 115 | asPrivateKey = await importPrivateKey(fixed.asPrivate, fixed.asPublic, "ecdh"); |
| 116 | asPublic = fromB64url(fixed.asPublic); |
| 117 | } else { |
| 118 | const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"])) as CryptoKeyPair; |
| 119 | asPrivateKey = pair.privateKey; |
| 120 | asPublic = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); |
| 121 | } |
| 122 | const salt = fixed?.salt ?? crypto.getRandomValues(new Uint8Array(16)); |
| 123 | const uaKey = await crypto.subtle.importKey("raw", buf(uaPublic), { name: "ECDH", namedCurve: "P-256" }, false, []); |
| 124 | const ecdhSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey } as unknown as SubtleCryptoDeriveKeyAlgorithm, asPrivateKey, 256)); |
| 125 | const { cek, nonce } = await keys({ ecdhSecret, auth, uaPublic, asPublic, salt }); |
| 126 | const aes = await crypto.subtle.importKey("raw", buf(cek), "AES-GCM", false, ["encrypt"]); |
| 127 | // The last (and only) record ends with the delimiter 0x02. |
| 128 | const record = new Uint8Array( |
| 129 | await crypto.subtle.encrypt({ name: "AES-GCM", iv: buf(nonce) }, aes, buf(concat(plaintext, new Uint8Array([2])))), |
| 130 | ); |
| 131 | const header = new Uint8Array(21); |
| 132 | header.set(salt, 0); |
| 133 | new DataView(header.buffer).setUint32(16, RECORD_SIZE); |
| 134 | header[20] = asPublic.length; |
| 135 | return concat(header, asPublic, record); |
| 136 | } |
| 137 | |
| 138 | /** The other way, as a browser does it: for tests, with the subscriber's private key. */ |
| 139 | export async function decrypt(body: Uint8Array, subscriber: { privateKey: string; publicKey: string; auth: string }): Promise<Uint8Array> { |
| 140 | const salt = body.slice(0, 16); |
| 141 | const idLength = body[20]; |
| 142 | const asPublic = body.slice(21, 21 + idLength); |
| 143 | const record = body.slice(21 + idLength); |
| 144 | const uaPrivate = await importPrivateKey(subscriber.privateKey, subscriber.publicKey, "ecdh"); |
| 145 | const asKey = await crypto.subtle.importKey("raw", buf(asPublic), { name: "ECDH", namedCurve: "P-256" }, false, []); |
| 146 | const ecdhSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: asKey } as unknown as SubtleCryptoDeriveKeyAlgorithm, uaPrivate, 256)); |
| 147 | const { cek, nonce } = await keys({ ecdhSecret, auth: fromB64url(subscriber.auth), uaPublic: fromB64url(subscriber.publicKey), asPublic, salt }); |
| 148 | const aes = await crypto.subtle.importKey("raw", buf(cek), "AES-GCM", false, ["decrypt"]); |
| 149 | const padded = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: buf(nonce) }, aes, buf(record))); |
| 150 | let end = padded.length - 1; |
| 151 | while (end >= 0 && padded[end] === 0) end--; |
| 152 | if (padded[end] !== 2) throw new Error("Not the last record."); |
| 153 | return padded.slice(0, end); |
| 154 | } |
| 155 | |
| 156 | export type PushResult = { endpoint: string; status: number; gone: boolean }; |
| 157 | |
| 158 | /** |
| 159 | * Sends one push. A 404 or 410 means the subscription is gone for good: |
| 160 | * the caller drops it. `topic` lets a newer push replace one still waiting |
| 161 | * (at most 32 URL-safe characters). |
| 162 | */ |
| 163 | export async function sendPush( |
| 164 | subscription: { endpoint: string; p256dh: string; auth: string }, |
| 165 | payload: object, |
| 166 | options: { vapid: Vapid; ttl?: number; urgency?: "very-low" | "low" | "normal" | "high"; topic?: string }, |
| 167 | fetcher: typeof fetch = fetch, |
| 168 | ): Promise<PushResult> { |
| 169 | const body = await encrypt(enc.encode(JSON.stringify(payload)), subscription); |
| 170 | const headers: Record<string, string> = { |
| 171 | authorization: await vapidAuthorization(subscription.endpoint, options.vapid), |
| 172 | "content-encoding": "aes128gcm", |
| 173 | "content-type": "application/octet-stream", |
| 174 | ttl: String(options.ttl ?? 24 * 3600), |
| 175 | urgency: options.urgency ?? "normal", |
| 176 | }; |
| 177 | const topic = options.topic?.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32); |
| 178 | if (topic) headers.topic = topic; |
| 179 | const response = await fetcher(subscription.endpoint, { method: "POST", headers, body: buf(body) }); |
| 180 | return { endpoint: subscription.endpoint, status: response.status, gone: response.status === 404 || response.status === 410 }; |
| 181 | } |
| 182 | |
| 183 | /** A new VAPID key pair, base64url: what scripts/ops/vapid-keys.mjs prints. */ |
| 184 | export async function generateVapidKeys(): Promise<{ publicKey: string; privateKey: string }> { |
| 185 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; |
| 186 | const jwk = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; |
| 187 | const raw = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); |
| 188 | return { publicKey: b64url(raw), privateKey: jwk.d! }; |
| 189 | } |