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
2 files src/background/chirp-sync-vault-adapter.test.ts+48 −0src/background/chirp-sync-vault-adapter.ts+7 −3
src/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" });

view file

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",

view file