2be6150ec6d1

M7/COR-007: distinguish vault corruption from wrong password

time
2026-05-16 02:56 UTC · 5 months ago
machine
koh2
parent
37c409102a5d
4 files src/background/messages.ts+13 −1src/background/vault.adversarial.test.ts+33 −0src/background/vault.ts+80 −7src/shared/types.ts+3 −0
src/background/messages.ts+13 −1
@@ -399,8 +399,20 @@399399           broadcastToContentScripts({ type: "VAULT_UNLOCKED" });400400           onVaultUnlocked();401401           if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());402+          return { ok: true };402403         }403-        return { ok };404+        // Distinguish corruption from wrong password so the popup can offer405+        // a history-restore path instead of falsely accusing the user of406+        // mistyping (M7/COR-007).407+        const failure = vault.consumeLastUnlockFailureReason();408+        if (failure) {409+          return {410+            ok: false,411+            error: "vault_corrupted",412+            canRestoreFromHistory: true,413+          } as MessageResponse;414+        }415+        return { ok: false };404416       }405417       case "VALIDATE_RECOVERY_WORDS": {406418         const { words } = msg.payload as { words: string[] };

view file

src/background/vault.adversarial.test.ts+33 −0
@@ -828,6 +828,39 @@828828     }829829   });830830 831+  // ── Corruption distinguished from wrong password (M7/COR-007) ───────────832+833+  describe("vault corruption surfaces vault_corrupted error", () => {834+    async function setup() {835+      const vault = await import("./vault");836+      await vault.createVault("test-pass-12345");837+      vault.lock();838+    }839+840+    it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {841+      await setup();842+      const a = storage.peach_vault_a as Record<string, unknown>;843+      storage.peach_vault_a = { ...a, version: 42 };844+      const r = await dispatch({845+        type: "UNLOCK",846+        payload: { password: "test-pass-12345" },847+      });848+      expect(r.ok).toBe(false);849+      expect((r as { error?: string }).error).toBe("vault_corrupted");850+      expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);851+    });852+853+    it("wrong password (correct schema) does NOT return vault_corrupted", async () => {854+      await setup();855+      const r = await dispatch({856+        type: "UNLOCK",857+        payload: { password: "wrong-password" },858+      });859+      expect(r.ok).toBe(false);860+      expect((r as { error?: string }).error).not.toBe("vault_corrupted");861+    });862+  });863+831864   // ── Many "add then lock then unlock" sequences ──────────────────────────832865 833866   describe("add-then-relock churn", () => {

view file

src/background/vault.ts+80 −7
@@ -66,6 +66,18 @@6666 6767 let migrationRan = false;6868 69+// Set to a corruption reason during the last unlock() attempt when both slots70+// decrypted but failed schema validation. Consumed and cleared by71+// consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted72+// distinctly from wrong-password (M7/COR-007).73+let lastUnlockFailureReason: string | null = null;74+75+export function consumeLastUnlockFailureReason(): string | null {76+  const r = lastUnlockFailureReason;77+  lastUnlockFailureReason = null;78+  return r;79+}80+6981 // ---------------------------------------------------------------------------7082 // Settings (persisted inside the encrypted vault store as a sidecar key)7183 // ---------------------------------------------------------------------------@@ -763,6 +775,29 @@763775   return { merged };764776 }765777 778+// Validates that a `StoredVault` record on disk has the field shape we expect779+// before treating an AES-GCM decrypt success as a clean unlock. AES-GCM only780+// authenticates the static VERSION_BYTE AAD, not the `version` field on the781+// outer record — a corrupted `version: 42` slot still decrypts cleanly. This782+// check surfaces that as `corrupted` (M7/COR-007) so the popup can offer a783+// history-restore path instead of falsely reporting "wrong password".784+function hasValidVaultSchema(v: unknown): v is StoredVault {785+  if (typeof v !== "object" || v === null) return false;786+  const r = v as Record<string, unknown>;787+  return (788+    r.version === 1 &&789+    typeof r.salt === "string" &&790+    typeof r.nonce === "string" &&791+    typeof r.ciphertext === "string" &&792+    typeof r.tag === "string"793+  );794+}795+796+export type TryDecryptResult =797+  | { status: "ok"; payload: DecryptedPayload }798+  | { status: "wrong_key" }799+  | { status: "corrupted"; reason: string };800+766801 // Exported solely for `unlockFromCodex` below, which needs to verify a767802 // supplied passphrase against a slotA blob *without* committing anything to768803 // storage. Keep this internal otherwise — callers crossing the messages.ts@@ -772,12 +807,35 @@772807   vault: StoredVault,773808   key: Uint8Array774809 ): Promise<DecryptedPayload | null> {810+  const res = await tryDecryptSlotResult(vault, key);811+  return res.status === "ok" ? res.payload : null;812+}813+814+export async function tryDecryptSlotResult(815+  vault: StoredVault,816+  key: Uint8Array817+): Promise<TryDecryptResult> {818+  if (!hasValidVaultSchema(vault)) {819+    return { status: "corrupted", reason: "invalid_slot_schema" };820+  }821+  let plain: string;775822   try {776-    const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);777-    return decodePayload(plain);823+    plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);778824   } catch {779-    return null;825+    return { status: "wrong_key" };780826   }827+  try {828+    const parsed = JSON.parse(plain) as unknown;829+    if (typeof parsed !== "object" || parsed === null) {830+      return { status: "corrupted", reason: "invalid_payload_shape" };831+    }832+    if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {833+      return { status: "corrupted", reason: "invalid_payload_shape" };834+    }835+    return { status: "ok", payload: decodePayload(plain) };836+  } catch {837+    return { status: "corrupted", reason: "invalid_payload_json" };838+  }781839 }782840 783841 // ---------------------------------------------------------------------------@@ -960,11 +1018,19 @@9601018     slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),9611019   ]);9621020 963-  // Attempt both decrypts in parallel.964-  const [payloadA, payloadB] = await Promise.all([965-    keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),966-    keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),1021+  // Attempt both decrypts in parallel. The Result variant lets us distinguish1022+  // 'wrong_key' from 'corrupted' so callers can offer a history-restore path1023+  // when a slot's schema is invalid even though AES-GCM authenticated (M7).1024+  const [resultA, resultB] = await Promise.all([1025+    keyA && slotA1026+      ? tryDecryptSlotResult(slotA, keyA)1027+      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1028+    keyB && slotB1029+      ? tryDecryptSlotResult(slotB, keyB)1030+      : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),9671031   ]);1032+  const payloadA = resultA.status === "ok" ? resultA.payload : null;1033+  const payloadB = resultB.status === "ok" ? resultB.payload : null;9681034 9691035   if (payloadA && keyA) {9701036     derivedKey = keyA;@@ -1025,6 +1091,13 @@10251091 10261092   if (keyA) zeroKey(keyA);10271093   if (keyB) zeroKey(keyB);1094+  // Surface corruption (good AES tag, broken schema) distinctly from1095+  // wrong-password so the popup can offer history-restore (M7/COR-007).1096+  if (resultA.status === "corrupted") {1097+    lastUnlockFailureReason = resultA.reason;1098+  } else if (resultB.status === "corrupted") {1099+    lastUnlockFailureReason = resultB.reason;1100+  }10281101   return false;10291102 }10301103 

view file

src/shared/types.ts+3 −0
@@ -460,4 +460,7 @@460460   data?: unknown;461461   error?: string;462462   unlocked?: boolean;463+  // Set on UNLOCK responses when the vault decrypted but failed schema464+  // validation (M7/COR-007). The popup may offer a history-restore flow.465+  canRestoreFromHistory?: boolean;463466 }

view file