Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/fxa-settings/src/lib/passkey-crypto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 35 additions & 11 deletions packages/fxa-settings/src/lib/passkey-crypto/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createWrapEnvelope,
openWrapEnvelope,
} from './envelope';
import { sealKb } from './hpke';

const UID = '0011223344556677889900aabbccddee';
const CREDENTIAL_ID = 'cGFzc2tleS1jcmVkZW50aWFsLWlk';
Expand Down Expand Up @@ -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;
Expand All @@ -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();

Expand Down
47 changes: 34 additions & 13 deletions packages/fxa-settings/src/lib/passkey-crypto/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
nshirley marked this conversation as resolved.
Resolved
);

try {
return await openKb(
{ encapsulatedSecret: hpkeEncapsulatedSecret, ciphertext: hpkeSealedKb },
pkR,
skRRaw,
context,
HPKE_AAD
);
return await openKb(sealed, pkR, skRRaw, context, HPKE_AAD);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} finally {
skRRaw.fill(0);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,7 +14,7 @@
"keyWrapIv": "a1b2c3d4e5f60718293a4b5c",
"pkR": "04014a963f6fd22ad4a52153d578a4f6c58e8ae21b043c9d94875644ac3bbbcb45f5ffd92a7580b46cf2d7217459b9d508655a4abef13cc9a347230cc25d552015433400be039a77b08db4ddb8d4dcbccc28007e07111d981102d25f98ecd11923e435b46b23e4df8fd5bee17235025b39454531de0bbecfb72c4a060da88b34fd09d8a854",
"skRRaw": "00c5ad512c4129bdba46c5f48cd7534166e9d1d354f14469e5b11cb38ebd66c15da8b16553a9b2eb25c0cd07bcfb3e14a3ff53ffa46430a8dcd57675e818622735eb",
"prfWrappedSkR": "82592fc4439ebac48933abd76fc50950cd78551a1548a8b2f7d1953b7af25d1effa6083a159c8d84b12fbca733c36ea72ef4efc83a14687e62c950986813788aae8e12695fcd7d1f6bd24b2c12e1e1f13b50",
"hpkeEncapsulatedSecret": "0400e802c49a3007e99940f7825303e508d5d352ffcee0f27c02eb7009dbbb01e63b1683b07bcd41ba3ba6266c9347b14872acf6e6eea5cc479e42f3ab6189fc7727bd01568a8f46e79c49b749000a2640d747306bb060c2ee39ecad6af8f9ef6e369bebe918cd1d2198b8ab5e45ef773c78098b36ec852bdfb9f64c5f799c2842656be4fa",
"hpkeSealedKb": "cbb84074fe00431676190d89614c15c819f4e7b605a944ed915b7619905d7d5db10d344ca77c5ebf9fb766d25a614e6b"
"prfWrappedSkR": "82592fc4439ebac48933abd76fc50950cd78551a1548a8b2f7d1953b7af25d1effa6083a159c8d84b12fbca733c36ea72ef4efc83a14687e62c950986813788aae8e2e9307dc796a58d2052792b55488dbfc",
"hpkeEncapsulatedSecret": "0401fefbab3be6a8c9fa5b615ec04fc076a78d63f2ba9fcd6456b08632573645eb745aaeef2177604d89cb560e3a72f24073de9910b286e816f7f8e2ad1efb25374615014347538b2c0ffb717b070d6a517153f78b001508c431919c609bf8197437204136238b101916550f3f97c7b1bb3373b8d602f26095f343e034027360b1e2e461af",
"hpkeSealedKb": "41efe707d595812d801e506a0dbce0126121aeb39aa77c6a42cd7117517ef06072a2d5dfc6916d3ec8e9a90693014772"
}
Loading