Skip to content
189 linesCodeBlameRaw
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
11const enc = new TextEncoder();
12
13export 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
19export 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. */
29function buf(bytes: Uint8Array): ArrayBuffer {
30 return bytes.slice().buffer as ArrayBuffer;
31}
32
33function 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`. */
44export 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
60export 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 */
67export 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. */
78export 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
82async 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. */
89async 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. */
98const RECORD_SIZE = 4096;
99
100/** Sender's own key pair and salt, given only by tests to reproduce a known vector. */
101export 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 */
108export 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. */
139export 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
156export 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 */
163export 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. */
184export 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}