0efc6954e26c
fix: HKDF null-byte separator in deriveEntryKey per-entry sync info (entryId\x00sync-entry-v1) — coordinated wire change with peach-android (brief PCH-01-005) (v3.8.1)
- time
- 2026-06-10 19:38 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- 048fd9fcc6de
↓ peach-browser-0efc6954.tar.gzatlas at this save
files changed2 files
src/background/chirp-sync-vault-adapter.test.ts+48 −0src/background/chirp-sync-vault-adapter.ts+7 −3src/background/chirp-sync-vault-adapter.test.ts+48 −0
@@ -193,6 +193,54 @@193193 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);194194 });195195 196+ it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {197+ const { vault, adapter } = await setupVault();198+ const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });199+ await vault.updateEntry(a.id, { title: "Fresh" });200+ const envelopes = await adapter.getEncryptedEntries([a.id]);201+ // Roll local back to an older copy, then apply: encrypt+decrypt both use the202+ // null-byte-separated info, so the round-trip must succeed.203+ const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };204+ await vault.restoreEntries([stale], "replace");205+ await adapter.applyEntries(envelopes);206+ expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");207+ });208+209+ it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {210+ // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and211+ // assert the patched adapter cannot decrypt it — it drops silently. Locks in212+ // the conscious wire break and serves as the fixture for any future migration.213+ const { vault, adapter } = await setupVault();214+ const { HKDF } = await import("@stablelib/hkdf");215+ const { SHA256 } = await import("@stablelib/sha256");216+217+ const masterHex = vault.getRootKeyHex()!;218+ const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));219+ const entryId = crypto.randomUUID();220+ const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00221+ const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);222+ const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);223+224+ const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };225+ const nonce = crypto.getRandomValues(new Uint8Array(12));226+ const aad = new TextEncoder().encode("peach-sync-entry-v1");227+ const ct = new Uint8Array(228+ await crypto.subtle.encrypt(229+ { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },230+ key,231+ new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,232+ ),233+ );234+ const envelope = new Uint8Array(nonce.length + ct.length);235+ envelope.set(nonce, 0);236+ envelope.set(ct, nonce.length);237+ const legacyB64 = Buffer.from(envelope).toString("base64");238+239+ await adapter.applyEntries({ [entryId]: legacyB64 });240+ // Decrypt fails under the new separated info → envelope dropped, entry absent.241+ expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);242+ });243+196244 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {197245 const { vault, adapter } = await setupVault();198246 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });src/background/chirp-sync-vault-adapter.ts+7 −3
@@ -7,7 +7,7 @@77 // here before being merged via `applyMergedRemoteEntries`.88 //99 // Envelope cipher: AES-GCM with a PER-ENTRY key derived as10-// HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "sync-entry-v1")10+// HKDF-SHA256(ikm = syncMasterKey, salt = "", info = entryId + "\x00" + "sync-entry-v1")1111 // (random 12B nonce, aad = "peach-sync-entry-v1"). Binding the key to the1212 // entry id means a ciphertext minted for one entry slot cannot be replayed1313 // into another: decryptEntryOrNull rejects any envelope whose decrypted@@ -57,7 +57,7 @@5757 SignedDeviceManifest,5858 } from "../shared/sync-types";5959 60-// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "sync-entry-v1").60+// Each entry uses a unique AES-GCM key: HKDF(masterKey, info = entryId + "\x00" + "sync-entry-v1").6161 // The info binding prevents a ciphertext from one entry slot being replayed into another.6262 const ENTRY_KEY_INFO_SUFFIX = "sync-entry-v1";6363 const ENTRY_AAD = "peach-sync-entry-v1";@@ -353,7 +353,11 @@353353 }354354 355355 async function deriveEntryKey(masterKey: Uint8Array, entryId: string): Promise<CryptoKey> {356- const info = new TextEncoder().encode(entryId + ENTRY_KEY_INFO_SUFFIX);356+ // Null-byte separator (HKDF hardening): entryId cannot contain \x00, so this357+ // makes the (entryId, suffix) boundary unambiguous regardless of future id358+ // formats. Coordinated wire change — peach-android VaultSyncAdapter derives359+ // the identical info; the two MUST stay byte-for-byte in lockstep.360+ const info = new TextEncoder().encode(`${entryId}\x00${ENTRY_KEY_INFO_SUFFIX}`);357361 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), info).expand(32);358362 return crypto.subtle.importKey(359363 "raw",