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
2 files src/background/messages.ts+14 −2src/background/sync.adversarial.test.ts+70 −0
src/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   };

view file

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;

view file