f9d291f0ea22
H3/COR-005: lock vault if reloadEntriesAfterSync fails after sync apply
- time
- 2026-05-16 02:14 UTC · 5 months ago
- machine
- koh2
- parent
- 35336be9b375
↓ peach-browser-f9d291f0.tar.gzatlas at this save
files changed2 files
src/background/messages.ts+14 −2src/background/sync.adversarial.test.ts+70 −0src/background/messages.ts+14 −2
@@ -168,7 +168,7 @@168168 // Sync helpers169169 // ---------------------------------------------------------------------------170170 171-function buildSyncDeps(): SyncDeps {171+export function buildSyncDeps(): SyncDeps {172172 return {173173 isVaultUnlocked: () => vault.isUnlocked(),174174 getVaultBlobForSync: async () => {@@ -189,7 +189,19 @@189189 applyIncomingVault: async (blob, ts, license, devices) => {190190 const applied = await vault.applyIncomingVault(blob, ts, license);191191 if (devices?.length) await mergeDeviceRegistry(devices);192- if (applied) await vault.reloadEntriesAfterSync();192+ if (applied) {193+ const reloaded = await vault.reloadEntriesAfterSync();194+ if (!reloaded) {195+ // The remote vault was encrypted under a different key (the remote196+ // changed password). Our in-memory entries are now stale and cannot197+ // be re-encrypted without overwriting the just-applied blob. Lock198+ // to force a re-unlock with the new password (H3/COR-005).199+ vault.lock();200+ chrome.runtime201+ .sendMessage({ type: "VAULT_RELOCK_AFTER_SYNC" })202+ .catch(() => {});203+ }204+ }193205 return applied;194206 },195207 };src/background/sync.adversarial.test.ts+70 −0
@@ -571,6 +571,76 @@571571 });572572 });573573 574+ // H3/COR-005 — when applyIncomingVault succeeds but reloadEntriesAfterSync575+ // fails (the remote was encrypted under a different password), the vault576+ // must lock rather than let stale in-memory entries clobber the new blob.577+ describe("H3/COR-005 sync re-decrypt failure lock", () => {578+ async function buildRemoteBlobWithDifferentKey(): Promise<string> {579+ const vault = await import("./vault");580+ const slotA = await vault.loadSlotA();581+ if (!slotA) throw new Error("no slot A");582+ // Re-use the same slot A shape but with a fresh random salt + a583+ // garbage ciphertext. Decryption will fail because the salt doesn't584+ // match the current in-memory derivedKey.585+ const rand = (n: number) =>586+ Array.from(crypto.getRandomValues(new Uint8Array(n)))587+ .map(b => b.toString(16).padStart(2, "0"))588+ .join("");589+ const remoteA = {590+ ...slotA,591+ salt: rand(16),592+ nonce: rand(12),593+ ciphertext: rand(64),594+ tag: rand(16),595+ };596+ return JSON.stringify({ a: remoteA, b: storage.peach_vault_b });597+ }598+599+ it("vault locks when reloadEntriesAfterSync fails after apply", async () => {600+ const vault = await setupBlobs();601+ const messages = await import("./messages");602+ const deps = messages.buildSyncDeps();603+ const remote = await buildRemoteBlobWithDifferentKey();604+ // Newer timestamp so applyIncomingVault writes.605+ const remoteTs = 1_700_000_000_000 + 100_000;606+ expect(vault.isUnlocked()).toBe(true);607+ const applied = await deps.applyIncomingVault(remote, remoteTs);608+ expect(applied).toBe(true);609+ expect(vault.isUnlocked()).toBe(false);610+ });611+612+ it("stale in-memory entries cannot clobber remote blob after sync", async () => {613+ const vault = await setupBlobs();614+ const messages = await import("./messages");615+ const deps = messages.buildSyncDeps();616+ const remote = await buildRemoteBlobWithDifferentKey();617+ const remoteTs = 1_700_000_000_000 + 100_000;618+ await deps.applyIncomingVault(remote, remoteTs);619+ // Vault is locked — any vault mutation must fail.620+ const r = await dispatch({621+ type: "ADD_ENTRY",622+ payload: { title: "Ghost", password: "p" },623+ });624+ expect(r.ok).toBe(false);625+ });626+627+ it("when reloadEntriesAfterSync succeeds, vault stays unlocked", async () => {628+ const vault = await setupBlobs();629+ const messages = await import("./messages");630+ const deps = messages.buildSyncDeps();631+ // Build a remote blob that decrypts under the *same* key — re-use632+ // current slot A but bump the timestamp via writeSlot equivalent.633+ const slotA = storage.peach_vault_a;634+ const sameKeyBlob = JSON.stringify({ a: slotA, b: storage.peach_vault_b });635+ const applied = await deps.applyIncomingVault(636+ sameKeyBlob,637+ 1_700_000_000_000 + 50_000,638+ );639+ expect(applied).toBe(true);640+ expect(vault.isUnlocked()).toBe(true);641+ });642+ });643+574644 // H1/THREAT-002 — responder must authenticate the initiator and reject any575645 // initiator whose authenticated X25519 public key is not in the paired list.576646 // The Noise NK transport itself is run by the native layer in production;