Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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(enc.encode(JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 3600, sub: vapid.subject })));
70 const key = await importPrivateKey(vapid.privateKey, vapid.publicKey, "sign");
71 const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, enc.encode(`${header}.${claims}`));
72 return `${header}.${claims}.${b64url(new Uint8Array(signature))}`;
73}
74
75/** The `Authorization` header a push carries. */
76export async function vapidAuthorization(endpoint: string, vapid: Vapid, now = Date.now()): Promise<string> {
77 return `vapid t=${await vapidJwt(endpoint, vapid, now)}, k=${vapid.publicKey}`;
78}
79
80async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, bytes: number): Promise<Uint8Array> {
81 const key = await crypto.subtle.importKey("raw", buf(ikm), "HKDF", false, ["deriveBits"]);
82 const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: buf(salt), info: buf(info) }, key, bytes * 8);
83 return new Uint8Array(bits);
84}
85
86/** The content key and nonce of RFC 8291 §3.4, from both sides' keys and the auth secret. */
87async function keys(input: { ecdhSecret: Uint8Array; auth: Uint8Array; uaPublic: Uint8Array; asPublic: Uint8Array; salt: Uint8Array }) {
88 const keyInfo = concat(enc.encode("WebPush: info\0"), input.uaPublic, input.asPublic);
89 const ikm = await hkdf(input.auth, input.ecdhSecret, keyInfo, 32);
90 const cek = await hkdf(input.salt, ikm, enc.encode("Content-Encoding: aes128gcm\0"), 16);
91 const nonce = await hkdf(input.salt, ikm, enc.encode("Content-Encoding: nonce\0"), 12);
92 return { cek, nonce };
93}
94
95/** The record size written in the header: one record holds the whole message. */
96const RECORD_SIZE = 4096;
97
98/** Sender's own key pair and salt, given only by tests to reproduce a known vector. */
99export type Fixed = { asPrivate: string; asPublic: string; salt: Uint8Array };
100
101/**
102 * `plaintext` encrypted for a subscription's `p256dh` and `auth`, as the
103 * body of a push: salt (16) ‖ record size (4) ‖ key id length (1) ‖ the
104 * sender's public key (65) ‖ the one record.
105 */
106export async function encrypt(plaintext: Uint8Array, subscription: { p256dh: string; auth: string }, fixed?: Fixed): Promise<Uint8Array> {
107 if (plaintext.length > RECORD_SIZE - 17 - 86) throw new Error("A push holds at most about 3,990 bytes.");
108 const uaPublic = fromB64url(subscription.p256dh);
109 const auth = fromB64url(subscription.auth);
110 let asPrivateKey: CryptoKey;
111 let asPublic: Uint8Array;
112 if (fixed) {
113 asPrivateKey = await importPrivateKey(fixed.asPrivate, fixed.asPublic, "ecdh");
114 asPublic = fromB64url(fixed.asPublic);
115 } else {
116 const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"])) as CryptoKeyPair;
117 asPrivateKey = pair.privateKey;
118 asPublic = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer);
119 }
120 const salt = fixed?.salt ?? crypto.getRandomValues(new Uint8Array(16));
121 const uaKey = await crypto.subtle.importKey("raw", buf(uaPublic), { name: "ECDH", namedCurve: "P-256" }, false, []);
122 const ecdhSecret = new Uint8Array(
123 await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey } as unknown as SubtleCryptoDeriveKeyAlgorithm, asPrivateKey, 256),
124 );
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(await crypto.subtle.encrypt({ name: "AES-GCM", iv: buf(nonce) }, aes, buf(concat(plaintext, new Uint8Array([2])))));
129 const header = new Uint8Array(21);
130 header.set(salt, 0);
131 new DataView(header.buffer).setUint32(16, RECORD_SIZE);
132 header[20] = asPublic.length;
133 return concat(header, asPublic, record);
134}
135
136/** The other way, as a browser does it: for tests, with the subscriber's private key. */
137export async function decrypt(body: Uint8Array, subscriber: { privateKey: string; publicKey: string; auth: string }): Promise<Uint8Array> {
138 const salt = body.slice(0, 16);
139 const idLength = body[20];
140 const asPublic = body.slice(21, 21 + idLength);
141 const record = body.slice(21 + idLength);
142 const uaPrivate = await importPrivateKey(subscriber.privateKey, subscriber.publicKey, "ecdh");
143 const asKey = await crypto.subtle.importKey("raw", buf(asPublic), { name: "ECDH", namedCurve: "P-256" }, false, []);
144 const ecdhSecret = new Uint8Array(
145 await crypto.subtle.deriveBits({ name: "ECDH", public: asKey } as unknown as SubtleCryptoDeriveKeyAlgorithm, uaPrivate, 256),
146 );
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}