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),
};
}
}
|