2be6150ec6d1
M7/COR-007: distinguish vault corruption from wrong password
- time
- 2026-05-16 02:56 UTC · 5 months ago
- machine
- koh2
- parent
- 37c409102a5d
↓ peach-browser-2be6150e.tar.gzatlas at this save
files changed4 files
src/background/messages.ts+13 −1src/background/vault.adversarial.test.ts+33 −0src/background/vault.ts+80 −7src/shared/types.ts+3 −0src/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[] };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", () => {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 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 }