All files / services/notification web-push.ts

100% Statements 110/110
100% Branches 16/16
100% Functions 9/9
100% Lines 107/107

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391                                                                        48x 48x 48x 2330x   48x               43x   43x 43x 43x 43x 1481x   43x                         36x             36x                                       13x 13x     13x           13x 13x                   13x     13x 13x                 12x             12x 12x   12x 12x                   13x           13x 13x 13x 13x                 12x 8x       4x 4x 1x       4x 4x 4x 4x 4x     4x 4x 4x 4x   4x   4x 4x 3x     1x     4x 4x 3x   1x     4x                     12x 12x     12x     12x             12x 12x                   12x             12x       12x     12x       12x 12x     12x 12x 12x   12x               12x 12x     12x 12x       12x 12x 12x     12x               12x               12x 12x 12x   12x     12x 12x     12x 12x 12x 12x 12x     12x 12x     12x 12x     12x   12x                       13x   13x           12x     12x                     10x 6x     4x 4x           3x              
/**
 * Web Push utility — self-contained implementation using Web Crypto API.
 * Compatible with Cloudflare Workers (no Node.js crypto).
 *
 * Implements:
 * - VAPID JWT signing (RFC 8292) with ES256
 * - Content encryption (RFC 8291) with aes128gcm encoding
 */
 
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
 
export type VapidKeys = {
	publicKey: string; // base64url encoded
	privateKey: string; // base64url encoded
	subject: string; // mailto: URL
};
 
export type PushSubscriptionData = {
	endpoint: string;
	p256dh: string; // base64url encoded
	auth: string; // base64url encoded
};
 
export type PushResult = {
	success: boolean;
	statusCode?: number;
	error?: string;
};
 
// ---------------------------------------------------------------------------
// Base64url helpers
// ---------------------------------------------------------------------------
 
export function base64urlEncode(buffer: ArrayBuffer | ArrayBufferLike): string {
	const bytes = new Uint8Array(buffer);
	let binary = "";
	for (let i = 0; i < bytes.length; i++) {
		binary += String.fromCharCode(bytes[i]);
	}
	return btoa(binary)
		.replace(/\+/g, "-")
		.replace(/\//g, "_")
		.replace(/=+$/, "");
}
 
export function base64urlDecode(str: string): Uint8Array {
	// Restore standard base64 characters
	const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
	// Add padding
	const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
	const binary = atob(padded);
	const bytes = new Uint8Array(binary.length);
	for (let i = 0; i < binary.length; i++) {
		bytes[i] = binary.charCodeAt(i);
	}
	return bytes;
}
 
// ---------------------------------------------------------------------------
// HKDF (RFC 5869) via Web Crypto
// ---------------------------------------------------------------------------
 
async function hkdf(
	ikm: ArrayBuffer,
	salt: ArrayBuffer,
	info: Uint8Array,
	length: number,
): Promise<ArrayBuffer> {
	const key = await crypto.subtle.importKey(
		"raw",
		ikm,
		{ name: "HKDF" },
		false,
		["deriveBits"],
	);
	return crypto.subtle.deriveBits(
		{
			name: "HKDF",
			hash: "SHA-256",
			salt,
			info,
		},
		key,
		length * 8, // deriveBits expects bits
	);
}
 
// ---------------------------------------------------------------------------
// VAPID JWT (RFC 8292, ES256)
// ---------------------------------------------------------------------------
 
export async function createVapidJwt(
	endpoint: string,
	vapidKeys: VapidKeys,
): Promise<{ authorization: string }> {
	const url = new URL(endpoint);
	const audience = `${url.protocol}//${url.host}`;
 
	// JWT header
	const header = base64urlEncode(
		new TextEncoder().encode(JSON.stringify({ typ: "JWT", alg: "ES256" }))
			.buffer as ArrayBuffer,
	);
 
	// JWT payload — expires in 12 hours
	const now = Math.floor(Date.now() / 1000);
	const payload = base64urlEncode(
		new TextEncoder().encode(
			JSON.stringify({
				aud: audience,
				exp: now + 12 * 60 * 60,
				sub: vapidKeys.subject,
			}),
		).buffer as ArrayBuffer,
	);
 
	const signingInput = `${header}.${payload}`;
 
	// Import the VAPID private key (raw P-256, 32 bytes)
	const privateKeyBytes = base64urlDecode(vapidKeys.privateKey);
	const privateKey = await crypto.subtle.importKey(
		"pkcs8",
		buildPkcs8FromRaw(privateKeyBytes),
		{ name: "ECDSA", namedCurve: "P-256" },
		false,
		["sign"],
	);
 
	// Sign with ES256 (ECDSA P-256 + SHA-256)
	const signatureBuffer = await crypto.subtle.sign(
		{ name: "ECDSA", hash: { name: "SHA-256" } },
		privateKey,
		new TextEncoder().encode(signingInput),
	);
 
	// Web Crypto returns DER-encoded signature; convert to raw r||s for JWT
	const signature = base64urlEncode(derToRaw(new Uint8Array(signatureBuffer)));
	const jwt = `${signingInput}.${signature}`;
 
	const authorization = `vapid t=${jwt},k=${vapidKeys.publicKey}`;
	return { authorization };
}
 
/**
 * Wrap a raw 32-byte P-256 private key scalar into PKCS#8 DER format.
 * The DER prefix is the fixed ASN.1 structure for an EC P-256 key.
 */
function buildPkcs8FromRaw(rawKey: Uint8Array): ArrayBuffer {
	// Minimal PKCS#8 DER wrapper for EC P-256 private key (omits optional public key).
	// Structure: SEQUENCE { version, AlgorithmIdentifier { ecPublicKey, P-256 }, OCTET STRING { ECPrivateKey } }
	const pkcs8Prefix = new Uint8Array([
		0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
		0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
		0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
	]);
 
	const result = new Uint8Array(pkcs8Prefix.length + rawKey.length);
	result.set(pkcs8Prefix);
	result.set(rawKey, pkcs8Prefix.length);
	return result.buffer as ArrayBuffer;
}
 
/**
 * Convert a DER-encoded ECDSA signature to raw r||s (64 bytes for P-256).
 * Web Crypto may return either format depending on the platform.
 */
function derToRaw(der: Uint8Array): ArrayBuffer {
	// If it's already 64 bytes, it's raw format
	if (der.length === 64) {
		return der.buffer as ArrayBuffer;
	}
 
	// DER: 0x30 <total-len> 0x02 <r-len> <r> 0x02 <s-len> <s>
	let offset = 2; // skip 0x30 + total length
	if (der[1] > 0x80) {
		offset += der[1] - 0x80;
	}
 
	// Parse r
	offset += 1; // skip 0x02
	const rLen = der[offset];
	offset += 1;
	const rStart = offset;
	offset += rLen;
 
	// Parse s
	offset += 1; // skip 0x02
	const sLen = der[offset];
	offset += 1;
	const sStart = offset;
 
	const raw = new Uint8Array(64);
	// Copy r (right-aligned to 32 bytes, skip leading zero if present)
	const rPadded = 32 - rLen;
	if (rPadded >= 0) {
		raw.set(der.slice(rStart, rStart + rLen), rPadded);
	} else {
		// r has a leading zero byte (33 bytes) — skip it
		raw.set(der.slice(rStart - rPadded, rStart + rLen), 0);
	}
	// Copy s (right-aligned to 32 bytes, skip leading zero if present)
	const sPadded = 32 - sLen;
	if (sPadded >= 0) {
		raw.set(der.slice(sStart, sStart + sLen), 32 + sPadded);
	} else {
		raw.set(der.slice(sStart - sPadded, sStart + sLen), 32);
	}
 
	return raw.buffer as ArrayBuffer;
}
 
// ---------------------------------------------------------------------------
// Content encryption (RFC 8291 — aes128gcm)
// ---------------------------------------------------------------------------
 
export async function encryptPayload(
	payload: string,
	subscription: PushSubscriptionData,
): Promise<ArrayBuffer> {
	const encoder = new TextEncoder();
	const plaintext = encoder.encode(payload);
 
	// 1. Generate salt (16 random bytes)
	const salt = crypto.getRandomValues(new Uint8Array(16));
 
	// 2. Generate ephemeral ECDH key pair
	const serverKeys = (await crypto.subtle.generateKey(
		{ name: "ECDH", namedCurve: "P-256" },
		true,
		["deriveBits"],
	)) as CryptoKeyPair;
 
	// 3. Import subscriber's p256dh public key
	const clientPublicKeyBytes = base64urlDecode(subscription.p256dh);
	const clientPublicKey = await crypto.subtle.importKey(
		"raw",
		clientPublicKeyBytes,
		{ name: "ECDH", namedCurve: "P-256" },
		false,
		[],
	);
 
	// 4. Derive shared secret via ECDH
	// Note: Cloudflare Workers types define `$public` but the runtime expects `public`
	const sharedSecret = await crypto.subtle.deriveBits(
		{ name: "ECDH", public: clientPublicKey } as unknown as SubtleCryptoDeriveKeyAlgorithm,
		serverKeys.privateKey,
		256,
	);
 
	// 5. Export server public key (uncompressed, 65 bytes)
	const serverPublicKeyBuffer = await crypto.subtle.exportKey(
		"raw",
		serverKeys.publicKey,
	);
	const serverPublicKey = new Uint8Array(serverPublicKeyBuffer as ArrayBuffer);
 
	// 6. Import auth secret
	const authSecret = base64urlDecode(subscription.auth);
 
	// 7. Derive PRK using HKDF with auth secret as salt
	// info = "WebPush: info\0" + client_public_key + server_public_key
	const infoPrefix = encoder.encode("WebPush: info\0");
	const keyInfo = new Uint8Array(
		infoPrefix.length + clientPublicKeyBytes.length + serverPublicKey.length,
	);
	keyInfo.set(infoPrefix);
	keyInfo.set(clientPublicKeyBytes, infoPrefix.length);
	keyInfo.set(serverPublicKey, infoPrefix.length + clientPublicKeyBytes.length);
 
	const prk = await hkdf(
		sharedSecret,
		authSecret.buffer as ArrayBuffer,
		keyInfo,
		32,
	);
 
	// 8. Derive content encryption key (CEK) — 16 bytes
	const cekInfo = encoder.encode("Content-Encoding: aes128gcm\0");
	const cek = await hkdf(prk, salt.buffer as ArrayBuffer, cekInfo, 16);
 
	// 9. Derive nonce — 12 bytes
	const nonceInfo = encoder.encode("Content-Encoding: nonce\0");
	const nonce = await hkdf(prk, salt.buffer as ArrayBuffer, nonceInfo, 12);
 
	// 10. Build plaintext record: content + padding delimiter (0x02) + zero padding
	// For a single record, the delimiter is 0x02 followed by no padding
	const paddedPlaintext = new Uint8Array(plaintext.length + 1);
	paddedPlaintext.set(plaintext);
	paddedPlaintext[plaintext.length] = 0x02; // delimiter byte
 
	// 11. Encrypt with AES-128-GCM
	const encryptionKey = await crypto.subtle.importKey(
		"raw",
		cek,
		{ name: "AES-GCM" },
		false,
		["encrypt"],
	);
 
	const encrypted = await crypto.subtle.encrypt(
		{ name: "AES-GCM", iv: new Uint8Array(nonce) },
		encryptionKey,
		paddedPlaintext,
	);
 
	// 12. Build final body: header + encrypted data
	// Header: salt(16) + rs(4, value 4096) + keyIdLen(1) + keyId(65 = server public key)
	const rs = 4096;
	const headerSize = 16 + 4 + 1 + serverPublicKey.length;
	const body = new Uint8Array(headerSize + encrypted.byteLength);
 
	let offset = 0;
 
	// Salt (16 bytes)
	body.set(salt, offset);
	offset += 16;
 
	// Record size (4 bytes, big-endian)
	body[offset] = (rs >> 24) & 0xff;
	body[offset + 1] = (rs >> 16) & 0xff;
	body[offset + 2] = (rs >> 8) & 0xff;
	body[offset + 3] = rs & 0xff;
	offset += 4;
 
	// Key ID length (1 byte)
	body[offset] = serverPublicKey.length;
	offset += 1;
 
	// Key ID (server public key, 65 bytes)
	body.set(serverPublicKey, offset);
	offset += serverPublicKey.length;
 
	// Encrypted record
	body.set(new Uint8Array(encrypted), offset);
 
	return body.buffer as ArrayBuffer;
}
 
// ---------------------------------------------------------------------------
// Main: send a web push notification
// ---------------------------------------------------------------------------
 
export async function sendWebPush(
	subscription: PushSubscriptionData,
	payload: string,
	vapidKeys: VapidKeys,
): Promise<PushResult> {
	try {
		// Generate VAPID authorization header
		const { authorization } = await createVapidJwt(
			subscription.endpoint,
			vapidKeys,
		);
 
		// Encrypt the payload
		const body = await encryptPayload(payload, subscription);
 
		// POST to the push service
		const response = await fetch(subscription.endpoint, {
			method: "POST",
			headers: {
				Authorization: authorization,
				"Content-Encoding": "aes128gcm",
				"Content-Type": "application/octet-stream",
				TTL: "86400",
			},
			body,
		});
 
		if (response.status >= 200 && response.status < 300) {
			return { success: true, statusCode: response.status };
		}
 
		const errorText = await response.text().catch(() => "(unreadable response body)");
		return {
			success: false,
			statusCode: response.status,
			error: errorText || `Push service returned ${response.status}`,
		};
	} catch (err) {
		return {
			success: false,
			statusCode: 0,
			error: err instanceof Error ? err.message : String(err),
		};
	}
}