diff --git a/packages/fxa-settings/src/lib/passkey-crypto/README.md b/packages/fxa-settings/src/lib/passkey-crypto/README.md index 015ef7609c6..db99e78cff3 100644 --- a/packages/fxa-settings/src/lib/passkey-crypto/README.md +++ b/packages/fxa-settings/src/lib/passkey-crypto/README.md @@ -90,7 +90,7 @@ A red golden test has two legitimate resolutions, and one illegitimate one. If the format genuinely has not shipped and must change: 1. Land the format change and get the round-trip tests green first, so the shape you freeze is the one that will ship. -2. Generate the vectors **outside this module**, in a throwaway Node script that imports only `hpke` and uses `crypto.subtle` directly. Do not import from `envelope.ts`, `hpke.ts` or `key-wrap.ts`: vectors produced by the code under test only restate what it does. Use fixed inputs so the run is reproducible: the existing `kB`, `prfOut`, `uid`, `credentialId` and `keyWrapIv` are fine to keep. The script must, by hand: frame the context as `len(uid) || uid || len(credentialId) || credentialId`, each length a 2-byte big-endian prefix, `uid` decoded from hex and `credentialId` from base64url; derive a 256-bit AES-GCM key from `prfOut` with HKDF-SHA512, empty salt, `info` = `KEY_WRAP_KDF_INFO`; left-pad the scalar to 66 bytes and AES-256-GCM it under `KEY_WRAP_AAD_LABEL || context` with the fixed `keyWrapIv`; and `Seal` `kB` to `pkR` with `HPKE_INFO_LABEL || context` as `info` and empty `aad`. Every constant is copied by value, not imported, so a typo fails the golden test rather than silently agreeing with the module. +2. Generate the vectors **outside this module**, in a throwaway Node script that imports only `hpke` and uses `crypto.subtle` directly. Do not import from `envelope.ts`, `hpke.ts` or `key-wrap.ts`: vectors produced by the code under test only restate what it does. Use fixed inputs so the run is reproducible: the existing `kB`, `prfOut`, `uid`, `credentialId` and `keyWrapIv` are fine to keep. The script must, by hand: frame the context as `len(uid) || uid || len(credentialId) || credentialId`, each length a 2-byte big-endian prefix, `uid` decoded from hex and `credentialId` from base64url; derive a 256-bit AES-GCM key from `prfOut` with HKDF-SHA512, empty salt, `info` = `KEY_WRAP_KDF_INFO`; `Seal` `kB` to `pkR` with `HPKE_INFO_LABEL || context` as `info` and empty `aad`; and left-pad the scalar to 66 bytes and AES-256-GCM it under `KEY_WRAP_AAD_LABEL || context || len(pkR) || pkR || len(enc) || enc || len(ct) || ct` (the same 2-byte prefixes) with the fixed `keyWrapIv`. Every constant is copied by value, not imported, so a typo fails the golden test rather than silently agreeing with the module. 3. Replace the vector fields in `v1-envelope-fixture.json`. Update `_provenance` with the date, commit, `hpke` version and reason. The reviewer should open the new envelope the same way, with a script that imports nothing from this module, and say so in the review; the `independentlyVerified` entry records the last time that was done. 4. Run the golden suite. The hash test fails and prints the digest it computed. Recompute it yourself to confirm, from the module directory, and update `FIXTURE_SHA256`. Nothing else in the test should need to change. diff --git a/packages/fxa-settings/src/lib/passkey-crypto/envelope.test.ts b/packages/fxa-settings/src/lib/passkey-crypto/envelope.test.ts index e029804a994..2f48ae573e0 100644 --- a/packages/fxa-settings/src/lib/passkey-crypto/envelope.test.ts +++ b/packages/fxa-settings/src/lib/passkey-crypto/envelope.test.ts @@ -9,6 +9,7 @@ import { createWrapEnvelope, openWrapEnvelope, } from './envelope'; +import { sealKb } from './hpke'; const UID = '0011223344556677889900aabbccddee'; const CREDENTIAL_ID = 'cGFzc2tleS1jcmVkZW50aWFsLWlk'; @@ -379,16 +380,12 @@ describe('openWrapEnvelope', () => { expect(hex(kB)).toBe(KB_HEX); }); - // Corrupted in place at the correct width, so normalizeEnvelope and the AES - // layer both pass and the failure comes from HPKE. Every other negative case - // here stops at the first layer. The two fields fail at different stages: - // the ciphertext at the AEAD open, the ephemeral key at decapsulation. - it.each([ - ['hpkeSealedKb', 'OpenError'], - ['hpkeEncapsulatedSecret', 'DecapError'], - ] as const)( - 'surfaces the HPKE failure for a corrupted %s', - async (field, name) => { + // Corrupted in place at the correct width, so normalizeEnvelope passes. The + // HPKE fields are bound into the skR wrap's aad, so the failure comes from + // the AES layer and HPKE never runs: the same tag check a wrong prfOut fails. + it.each(['hpkeSealedKb', 'hpkeEncapsulatedSecret'] as const)( + 'surfaces the decryption failure for a corrupted %s', + async (field) => { const envelope = await create(); const corrupted = Uint8Array.from(envelope[field]); corrupted[0] ^= 0xff; @@ -400,10 +397,37 @@ describe('openWrapEnvelope', () => { uid: UID, credentialId: CREDENTIAL_ID, }) - ).rejects.toThrow(expect.objectContaining({ name })); + ).rejects.toThrow(expect.objectContaining({ name: 'OperationError' })); } ); + // The position of anyone holding the stored row but not prfOut: pkR is in + // the clear, so a kB of their choosing can be sealed to it. The skR wrap + // must refuse to open against the substituted fields rather than hand the + // genuine skR to HPKE, which would open them to the chosen kB. + it('rejects a kB resealed to the stored pkR by a holder of the row', async () => { + const envelope = await create(); + const resealed = await sealKb( + new Uint8Array(32).fill(0x42), + envelope.pkR, + context(), + new Uint8Array(0) + ); + + await expect( + openWrapEnvelope({ + envelope: { + ...envelope, + hpkeEncapsulatedSecret: resealed.encapsulatedSecret, + hpkeSealedKb: resealed.ciphertext, + }, + prfOut: bytes(PRF_OUT_HEX), + uid: UID, + credentialId: CREDENTIAL_ID, + }) + ).rejects.toThrow(expect.objectContaining({ name: 'OperationError' })); + }); + it('surfaces the decryption failure under a different credential', async () => { const envelope = await create(); diff --git a/packages/fxa-settings/src/lib/passkey-crypto/envelope.ts b/packages/fxa-settings/src/lib/passkey-crypto/envelope.ts index 74689b431d6..b43421bdca2 100644 --- a/packages/fxa-settings/src/lib/passkey-crypto/envelope.ts +++ b/packages/fxa-settings/src/lib/passkey-crypto/envelope.ts @@ -23,7 +23,7 @@ import { concat } from 'hpke'; import { base64urlToBytes } from '../base64url'; import { assertByteLength } from './assert'; import { CREDENTIAL_ID_MAX_BYTES, UID_BYTES, V1_SIZES } from './constants'; -import { openKb, sealKb } from './hpke'; +import { openKb, sealKb, type SealedKb } from './hpke'; import { generateRecipientKeyPair, unwrapRecipientPrivateKey, @@ -127,8 +127,8 @@ function normalizeEnvelope(envelope: unknown): PasskeyWrapEnvelope { } /** - * Builds the context bound into a v1 envelope, as the HPKE `info` and the `skR` - * wrap's `aad`. + * Builds the context bound into a v1 envelope, as the HPKE `info` and the first + * part of the `skR` wrap's `aad` — see `buildKeyWrapAad`. * * FROZEN FORMAT CONTRACT. Framing is length-prefixed rather than delimited. * That is insurance, not a fix for a live ambiguity: `uid` is a fixed 16 bytes, @@ -156,6 +156,29 @@ export function buildEnvelopeContext({ return frame(uidBytes, credentialIdBytes); } +/** + * Builds the `aad` for the `skR` wrap: the credential context, then the HPKE + * fields the unwrapped key will be used against, length-framed like the + * context so the parts cannot shift into one another. + * + * FROZEN FORMAT CONTRACT. This is what stops a holder of the stored row from + * choosing the `kB` it opens to. `pkR` is stored in the clear and HPKE + * `mode_base` authenticates nothing a PRF holder can check, so anyone with the + * row could `Seal` their own `kB` to `pkR` and the genuine `skR` would open + * it. Binding `pkR`, `enc` and `ct` here means a substituted field fails the + * AES-GCM tag over `skR` before HPKE ever runs. + */ +function buildKeyWrapAad( + context: Uint8Array, + pkR: Uint8Array, + sealed: SealedKb +): Uint8Array { + return concat( + context, + frame(pkR, sealed.encapsulatedSecret, sealed.ciphertext) + ); +} + /** * Wraps `kB` for one passkey, returning the envelope to store. * @@ -176,12 +199,12 @@ export async function createWrapEnvelope({ const { publicKey, privateKeyRaw } = await generateRecipientKeyPair(); try { + const sealed = await sealKb(kB, publicKey, context, HPKE_AAD); const { wrapped, iv } = await wrapRecipientPrivateKey( privateKeyRaw, prfOut, - context + buildKeyWrapAad(context, publicKey, sealed) ); - const sealed = await sealKb(kB, publicKey, context, HPKE_AAD); return { pkR: publicKey, @@ -219,22 +242,20 @@ export async function openWrapEnvelope({ hpkeSealedKb, } = normalizeEnvelope(envelope); const context = buildEnvelopeContext({ uid, credentialId }); + const sealed = { + encapsulatedSecret: hpkeEncapsulatedSecret, + ciphertext: hpkeSealedKb, + }; const skRRaw = await unwrapRecipientPrivateKey( prfWrappedSkR, keyWrapIv, prfOut, - context + buildKeyWrapAad(context, pkR, sealed) ); try { - return await openKb( - { encapsulatedSecret: hpkeEncapsulatedSecret, ciphertext: hpkeSealedKb }, - pkR, - skRRaw, - context, - HPKE_AAD - ); + return await openKb(sealed, pkR, skRRaw, context, HPKE_AAD); } finally { skRRaw.fill(0); } diff --git a/packages/fxa-settings/src/lib/passkey-crypto/golden-envelope.test.ts b/packages/fxa-settings/src/lib/passkey-crypto/golden-envelope.test.ts index 8c4fd70fc3c..9230fbf587f 100644 --- a/packages/fxa-settings/src/lib/passkey-crypto/golden-envelope.test.ts +++ b/packages/fxa-settings/src/lib/passkey-crypto/golden-envelope.test.ts @@ -59,7 +59,7 @@ const VECTOR_FIELDS = [ * any vector means changing this too, deliberately, in a second file. */ const FIXTURE_SHA256 = - 'c99094fa17288839e96a7a36ede5c8a62919af75254a99649adf361867b562f0'; + 'c66786e23a4a02ec598bfde299dea1bc5cf5c19222198ec0c2ea627b1e6929ec'; describe('frozen v1 envelope', () => { const context = bytes(fixture.context); @@ -89,12 +89,29 @@ describe('frozen v1 envelope', () => { ).toBe(fixture.context); }); + // The skR wrap's aad is the context followed by the HPKE fields, each with a + // 2-byte big-endian length prefix. Built here by hand so a drift in the + // module's framing fails this rather than agreeing with itself. + const frame = (...parts: Uint8Array[]) => + Buffer.concat( + parts.flatMap((part) => [ + Buffer.from([(part.length >> 8) & 0xff, part.length & 0xff]), + Buffer.from(part), + ]) + ); + const keyWrapAad = Uint8Array.from( + Buffer.concat([ + Buffer.from(context), + frame(envelope.pkR, sealedKb.encapsulatedSecret, sealedKb.ciphertext), + ]) + ); + it('unwraps skR to the recorded scalar', async () => { const unwrapped = await unwrapRecipientPrivateKey( envelope.prfWrappedSkR, envelope.keyWrapIv, bytes(fixture.prfOut), - context + keyWrapAad ); expect(hex(unwrapped)).toBe(fixture.skRRaw); diff --git a/packages/fxa-settings/src/lib/passkey-crypto/v1-envelope-fixture.json b/packages/fxa-settings/src/lib/passkey-crypto/v1-envelope-fixture.json index 4d0d37ca632..68d31ed47d3 100644 --- a/packages/fxa-settings/src/lib/passkey-crypto/v1-envelope-fixture.json +++ b/packages/fxa-settings/src/lib/passkey-crypto/v1-envelope-fixture.json @@ -2,9 +2,9 @@ "_comment": "Generated from the ciphersuite and Web Crypto directly, not through the module. Not regenerable once any client has written a v1 envelope. See the README under \"Regenerating the fixture\" and the hash pin in golden-envelope.test.ts.", "_provenance": { "generated": "2026-08-14 to 2026-08-18 during FXA-13144 (PR #21006, commit 1d572d471f), from a throwaway Node script importing hpke 1.1.3 and using crypto.subtle directly. kB, prfOut, keyWrapIv, pkR and skRRaw are fixed inputs and have never changed.", - "hpkeFieldsRegenerated": "2026-09-04 during FXA-13147 (commit 636b874d03) when keysChangedAt left the aad and the context moved wholly into info. prfWrappedSkR was unaffected. No client had written an envelope.", - "independentlyVerified": "2026-09-14, FXA-14269: all three layers (context framing, HKDF + AES-GCM unwrap, HPKE open) reproduced with hpke 1.1.3 and crypto.subtle alone, constants copied by value, no import from this module.", - "frozenSince": "2026-09-14, FXA-14269" + "hpkeFieldsRegenerated": "2026-09-04 during FXA-13147 (commit 636b874d03) when keysChangedAt left the aad and the context moved wholly into info. prfWrappedSkR was unaffected. Regenerated again 2026-10-15 during FXA-14742 with hpke 1.1.3 when pkR, enc and ct were bound into the skR wrap's aad; prfWrappedSkR changed in its GCM tag only (same key, iv and scalar). No production client had written an envelope at either point.", + "independentlyVerified": "2026-11-02, FXA-14742: all three layers (context framing, HKDF + AES-GCM unwrap under the new aad, HPKE open) and the FIXTURE_SHA256 pin reproduced with hpke 1.1.3 and crypto.subtle alone, constants copied by value, no import from this module. The context-only aad was confirmed to fail as a negative control.", + "frozenSince": "2026-10-15, FXA-14742" }, "kB": "00112233445566778899aabbccddeeff102030405060708090a0b0c0d0e0f001", "prfOut": "fedcba98765432100123456789abcdeff0e1d2c3b4a5968778695a4b3c2d1e0f", @@ -14,7 +14,7 @@ "keyWrapIv": "a1b2c3d4e5f60718293a4b5c", "pkR": "04014a963f6fd22ad4a52153d578a4f6c58e8ae21b043c9d94875644ac3bbbcb45f5ffd92a7580b46cf2d7217459b9d508655a4abef13cc9a347230cc25d552015433400be039a77b08db4ddb8d4dcbccc28007e07111d981102d25f98ecd11923e435b46b23e4df8fd5bee17235025b39454531de0bbecfb72c4a060da88b34fd09d8a854", "skRRaw": "00c5ad512c4129bdba46c5f48cd7534166e9d1d354f14469e5b11cb38ebd66c15da8b16553a9b2eb25c0cd07bcfb3e14a3ff53ffa46430a8dcd57675e818622735eb", - "prfWrappedSkR": "82592fc4439ebac48933abd76fc50950cd78551a1548a8b2f7d1953b7af25d1effa6083a159c8d84b12fbca733c36ea72ef4efc83a14687e62c950986813788aae8e12695fcd7d1f6bd24b2c12e1e1f13b50", - "hpkeEncapsulatedSecret": "0400e802c49a3007e99940f7825303e508d5d352ffcee0f27c02eb7009dbbb01e63b1683b07bcd41ba3ba6266c9347b14872acf6e6eea5cc479e42f3ab6189fc7727bd01568a8f46e79c49b749000a2640d747306bb060c2ee39ecad6af8f9ef6e369bebe918cd1d2198b8ab5e45ef773c78098b36ec852bdfb9f64c5f799c2842656be4fa", - "hpkeSealedKb": "cbb84074fe00431676190d89614c15c819f4e7b605a944ed915b7619905d7d5db10d344ca77c5ebf9fb766d25a614e6b" + "prfWrappedSkR": "82592fc4439ebac48933abd76fc50950cd78551a1548a8b2f7d1953b7af25d1effa6083a159c8d84b12fbca733c36ea72ef4efc83a14687e62c950986813788aae8e2e9307dc796a58d2052792b55488dbfc", + "hpkeEncapsulatedSecret": "0401fefbab3be6a8c9fa5b615ec04fc076a78d63f2ba9fcd6456b08632573645eb745aaeef2177604d89cb560e3a72f24073de9910b286e816f7f8e2ad1efb25374615014347538b2c0ffb717b070d6a517153f78b001508c431919c609bf8197437204136238b101916550f3f97c7b1bb3373b8d602f26095f343e034027360b1e2e461af", + "hpkeSealedKb": "41efe707d595812d801e506a0dbce0126121aeb39aa77c6a42cd7117517ef06072a2d5dfc6916d3ec8e9a90693014772" }