{"version":3,"file":"encryption.mjs","names":[],"sources":["../src/encryption.ts"],"sourcesContent":["/**\n * Web Push Message Encryption (RFC 8291 / RFC 8188 aes128gcm)\n *\n * Encryption flow:\n *   1. Generate an ephemeral ECDH P-256 key pair and a random 16-byte salt\n *   2. Derive a shared secret via ECDH with the subscriber's p256dh public key\n *   3. Derive IKM: HKDF(salt=auth_secret, ikm=shared_secret, info=\"WebPush: info\\0\" || subscriber_pub || local_pub)\n *   4. Derive CEK: HKDF(salt=salt, ikm=IKM, info=\"Content-Encoding: aes128gcm\\0\") -> 16 bytes\n *   5. Derive nonce: HKDF(salt=salt, ikm=IKM, info=\"Content-Encoding: nonce\\0\") -> 12 bytes\n *   6. Encrypt payload with AES-128-GCM using CEK and nonce\n *   7. Build aes128gcm header: [salt (16)] [record_size (4)] [keyid_len (1)] [keyid (65)]\n *   8. Concatenate header + ciphertext\n */\n\nfunction concat(...arrays: Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer> {\n\tconst totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);\n\tconst result = new Uint8Array(totalLength);\n\tlet offset = 0;\n\tfor (const arr of arrays) {\n\t\tresult.set(arr, offset);\n\t\toffset += arr.length;\n\t}\n\treturn result;\n}\n\nasync function hkdfDerive(\n\tsalt: Uint8Array<ArrayBuffer>,\n\tikm: Uint8Array<ArrayBuffer>,\n\tinfo: Uint8Array<ArrayBuffer>,\n\tlength: number,\n): Promise<Uint8Array<ArrayBuffer>> {\n\tconst key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);\n\treturn new Uint8Array(\n\t\tawait crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8),\n\t);\n}\n\n/**\n * Encrypts a payload per RFC 8291 (Web Push Message Encryption) with aes128gcm encoding.\n * Returns the complete body to POST to the push service (header + ciphertext).\n */\nexport async function encryptPayload(\n\tpayload: string | Uint8Array<ArrayBuffer>,\n\tsubscriberPublicKey: Uint8Array<ArrayBuffer>,\n\tauthSecret: Uint8Array<ArrayBuffer>,\n): Promise<Uint8Array<ArrayBuffer>> {\n\tconst payloadBytes = typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;\n\n\t// Step 1: Random salt ensures each message produces unique ciphertext\n\tconst salt = new Uint8Array(16);\n\tcrypto.getRandomValues(salt);\n\n\t// Step 1: Ephemeral ECDH key pair (new for each message)\n\tconst localKeyPair = await crypto.subtle.generateKey(\n\t\t{ name: 'ECDH', namedCurve: 'P-256' },\n\t\ttrue,\n\t\t['deriveBits'],\n\t);\n\tconst localPublicKeyRaw = new Uint8Array(\n\t\tawait crypto.subtle.exportKey('raw', localKeyPair.publicKey),\n\t);\n\n\t// Step 2: ECDH shared secret between our ephemeral key and the subscriber's key\n\tconst subscriberPubKey = await crypto.subtle.importKey(\n\t\t'raw',\n\t\tsubscriberPublicKey,\n\t\t{ name: 'ECDH', namedCurve: 'P-256' },\n\t\tfalse,\n\t\t[],\n\t);\n\tconst sharedSecret = new Uint8Array(\n\t\tawait crypto.subtle.deriveBits(\n\t\t\t{ name: 'ECDH', public: subscriberPubKey },\n\t\t\tlocalKeyPair.privateKey,\n\t\t\t256,\n\t\t),\n\t);\n\n\t// Step 3: Derive IKM from shared secret using auth secret as HKDF salt\n\tconst keyInfoBuf = concat(\n\t\tnew TextEncoder().encode('WebPush: info\\0'),\n\t\tsubscriberPublicKey,\n\t\tlocalPublicKeyRaw,\n\t);\n\tconst ikm = await hkdfDerive(authSecret, sharedSecret, keyInfoBuf, 32);\n\n\t// Step 4: Derive CEK (Content Encryption Key, 16 bytes)\n\tconst contentEncryptionKey = await hkdfDerive(\n\t\tsalt,\n\t\tikm,\n\t\tnew TextEncoder().encode('Content-Encoding: aes128gcm\\0'),\n\t\t16,\n\t);\n\n\t// Step 5: Derive nonce (12 bytes)\n\tconst nonce = await hkdfDerive(\n\t\tsalt,\n\t\tikm,\n\t\tnew TextEncoder().encode('Content-Encoding: nonce\\0'),\n\t\t12,\n\t);\n\n\t// Pad payload with a delimiter byte (0x02) per RFC 8188 section 2\n\tconst paddedPayload = concat(payloadBytes, new Uint8Array([2]));\n\n\t// Step 6: AES-128-GCM encryption\n\tconst aesKey = await crypto.subtle.importKey('raw', contentEncryptionKey, 'AES-GCM', false, [\n\t\t'encrypt',\n\t]);\n\tconst encrypted = new Uint8Array(\n\t\tawait crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, paddedPayload),\n\t);\n\n\t// Step 7: Build aes128gcm header (RFC 8188 section 2.1)\n\t// [salt (16 bytes)] [record_size (4 bytes BE)] [keyid_len (1 byte)] [keyid (65 bytes)]\n\tconst recordSize = new Uint8Array(4);\n\tnew DataView(recordSize.buffer).setUint32(0, 4096);\n\n\tconst header = concat(\n\t\tsalt,\n\t\trecordSize,\n\t\tnew Uint8Array([localPublicKeyRaw.length]),\n\t\tlocalPublicKeyRaw,\n\t);\n\n\t// Step 8: Concatenate header + ciphertext\n\treturn concat(header, encrypted);\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,SAAS,OAAO,GAAG,QAA4D;CAC9E,MAAM,cAAc,OAAO,QAAQ,KAAK,QAAQ,MAAM,IAAI,QAAQ,EAAE;CACpE,MAAM,SAAS,IAAI,WAAW,YAAY;CAC1C,IAAI,SAAS;AACb,MAAK,MAAM,OAAO,QAAQ;AACzB,SAAO,IAAI,KAAK,OAAO;AACvB,YAAU,IAAI;;AAEf,QAAO;;AAGR,eAAe,WACd,MACA,KACA,MACA,QACmC;CACnC,MAAM,MAAM,MAAM,OAAO,OAAO,UAAU,OAAO,KAAK,QAAQ,OAAO,CAAC,aAAa,CAAC;AACpF,QAAO,IAAI,WACV,MAAM,OAAO,OAAO,WAAW;EAAE,MAAM;EAAQ,MAAM;EAAW;EAAM;EAAM,EAAE,KAAK,SAAS,EAAE,CAC9F;;;;;;AAOF,eAAsB,eACrB,SACA,qBACA,YACmC;CACnC,MAAM,eAAe,OAAO,YAAY,WAAW,IAAI,aAAa,CAAC,OAAO,QAAQ,GAAG;CAGvF,MAAM,OAAO,IAAI,WAAW,GAAG;AAC/B,QAAO,gBAAgB,KAAK;CAG5B,MAAM,eAAe,MAAM,OAAO,OAAO,YACxC;EAAE,MAAM;EAAQ,YAAY;EAAS,EACrC,MACA,CAAC,aAAa,CACd;CACD,MAAM,oBAAoB,IAAI,WAC7B,MAAM,OAAO,OAAO,UAAU,OAAO,aAAa,UAAU,CAC5D;CAGD,MAAM,mBAAmB,MAAM,OAAO,OAAO,UAC5C,OACA,qBACA;EAAE,MAAM;EAAQ,YAAY;EAAS,EACrC,OACA,EAAE,CACF;CAeD,MAAM,MAAM,MAAM,WAAW,YAdR,IAAI,WACxB,MAAM,OAAO,OAAO,WACnB;EAAE,MAAM;EAAQ,QAAQ;EAAkB,EAC1C,aAAa,YACb,IACA,CACD,EAGkB,OAClB,IAAI,aAAa,CAAC,OAAO,kBAAkB,EAC3C,qBACA,kBACA,EACkE,GAAG;CAGtE,MAAM,uBAAuB,MAAM,WAClC,MACA,KACA,IAAI,aAAa,CAAC,OAAO,gCAAgC,EACzD,GACA;CAGD,MAAM,QAAQ,MAAM,WACnB,MACA,KACA,IAAI,aAAa,CAAC,OAAO,4BAA4B,EACrD,GACA;CAGD,MAAM,gBAAgB,OAAO,cAAc,IAAI,WAAW,CAAC,EAAE,CAAC,CAAC;CAG/D,MAAM,SAAS,MAAM,OAAO,OAAO,UAAU,OAAO,sBAAsB,WAAW,OAAO,CAC3F,UACA,CAAC;CACF,MAAM,YAAY,IAAI,WACrB,MAAM,OAAO,OAAO,QAAQ;EAAE,MAAM;EAAW,IAAI;EAAO,EAAE,QAAQ,cAAc,CAClF;CAID,MAAM,aAAa,IAAI,WAAW,EAAE;AACpC,KAAI,SAAS,WAAW,OAAO,CAAC,UAAU,GAAG,KAAK;AAUlD,QAAO,OARQ,OACd,MACA,YACA,IAAI,WAAW,CAAC,kBAAkB,OAAO,CAAC,EAC1C,kBACA,EAGqB,UAAU"}