async function HKDF({ salt, ikm, info, length }) { return await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info }, await crypto.subtle.importKey("raw", ikm, { name: "HKDF" }, false, [ "deriveBits", ]), length * 8 ); } // https://datatracker.ietf.org/doc/html/rfc8188#section-2.2 // https://datatracker.ietf.org/doc/html/rfc8188#section-2.3 async function deriveKeyAndNonce(header) { const { salt } = header; const ikm = await getInputKeyingMaterial(header); // cek_info = "Content-Encoding: aes128gcm" || 0x00 const cekInfo = new TextEncoder().encode("Content-Encoding: aes128gcm\0"); // nonce_info = "Content-Encoding: nonce" || 0x00 const nonceInfo = new TextEncoder().encode("Content-Encoding: nonce\0"); // (The XOR SEQ is skipped as we only create single record here, thus becoming noop) return { // the length (L) parameter to HKDF is 16 key: await HKDF({ salt, ikm, info: cekInfo, length: 16 }), // The length (L) parameter is 12 octets nonce: await HKDF({ salt, ikm, info: nonceInfo, length: 12 }), }; } // https://datatracker.ietf.org/doc/html/rfc8291#section-3.3 // https://datatracker.ietf.org/doc/html/rfc8291#section-3.4 async function getInputKeyingMaterial(header) { // IKM: the shared secret derived using ECDH // ecdh_secret = ECDH(as_private, ua_public) const ikm = await crypto.subtle.deriveBits( { name: "ECDH", public: await crypto.subtle.importKey( "raw", header.userAgentPublicKey, { name: "ECDH", namedCurve: "P-256" }, true, [] ), }, header.appServer.privateKey, 256 ); // key_info = "WebPush: info" || 0x00 || ua_public || as_public const keyInfo = new Uint8Array([ ...new TextEncoder().encode("WebPush: info\0"), ...header.userAgentPublicKey, ...header.appServer.publicKey, ]) return await HKDF({ salt: header.authSecret, ikm, info: keyInfo, length: 32 }); } // https://datatracker.ietf.org/doc/html/rfc8188#section-2 async function encryptRecord(key, nonce, data) { // add a delimiter octet (0x01 or 0x02) // The last record uses a padding delimiter octet set to the value 2 // // (This implementation only creates a single record, thus always 2, // per https://datatracker.ietf.org/doc/html/rfc8291/#section-4: // An application server MUST encrypt a push message with a single // record.) const padded = new Uint8Array([...data, 2]); // encrypt with AEAD_AES_128_GCM return await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, tagLength: 128 }, await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ "encrypt", ]), padded ); } // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1 function writeHeader(header) { var dataView = new DataView(new ArrayBuffer(5)); dataView.setUint32(0, header.recordSize); dataView.setUint8(4, header.keyid.length); return new Uint8Array([ ...header.salt, ...new Uint8Array(dataView.buffer), ...header.keyid, ]); } function validateParams(params) { const header = { ...params }; if (!header.salt) { throw new Error("Must include a salt parameter"); } if (header.salt.length !== 16) { // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1 // The "salt" parameter comprises the first 16 octets of the // "aes128gcm" content-coding header. throw new Error("The salt parameter must be 16 bytes"); } if (header.appServer.publicKey.byteLength !== 65) { // https://datatracker.ietf.org/doc/html/rfc8291#section-4 // A push message MUST include the application server ECDH public key in // the "keyid" parameter of the encrypted content coding header. The // uncompressed point form defined in [X9.62] (that is, a 65-octet // sequence that starts with a 0x04 octet) forms the entirety of the // "keyid". throw new Error("The appServer.publicKey parameter must be 65 bytes"); } if (!header.authSecret) { throw new Error("No authentication secret for webpush"); } return header; } export async function encrypt(data, params) { const header = validateParams(params); // https://datatracker.ietf.org/doc/html/rfc8291#section-2 // The ECDH public key is encoded into the "keyid" parameter of the encrypted content coding header header.keyid = header.appServer.publicKey; header.recordSize = data.byteLength + 18 + 1; // https://datatracker.ietf.org/doc/html/rfc8188#section-2 // The final encoding consists of a header (see Section 2.1) and zero or more // fixed-size encrypted records; the final record can be smaller than the record size. const saltedHeader = writeHeader(header); const { key, nonce } = await deriveKeyAndNonce(header); const encrypt = await encryptRecord(key, nonce, data); return new Uint8Array([...saltedHeader, ...new Uint8Array(encrypt)]); }