c9f2283eb546
fix(security): duress pads both vault slots to a fixed 256KiB ceiling so ciphertext lengths never reveal a hidden decoy vault exists — restores plausible deniability across real-vault growth (MEDIUM #5) (v3.6.127)
- time
- 2026-05-28 22:39 UTC · 5 months ago
- machine
- koh2
- parent
- 935ae427d7ee
↓ peach-browser-c9f2283e.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+18 −0src/background/vault.ts+30 −18manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.126",5+ "version": "3.6.127",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.126",4+ "version": "3.6.127",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.126",3+ "version": "3.6.127",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/vault.test.ts+18 −0
@@ -1035,6 +1035,24 @@10351035 expect(vault.getRootKeyHex()).toBeNull();10361036 });10371037 1038+ it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1039+ const vault = await import("./vault");1040+ await vault.createVault("duress-real-pass-aaaa");1041+ await vault.setupDuress("duress-decoy-pass-bbbb");1042+1043+ const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1044+ const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;1045+1046+ // Equal immediately after setup…1047+ expect(lenA()).toBe(lenB());1048+1049+ // …and STILL equal after real-vault edits (pre-fix, slot A grew per 1 KiB1050+ // bucket while the frozen decoy stayed put, leaking that duress exists).1051+ await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1052+ await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1053+ expect(lenA()).toBe(lenB());1054+ });1055+10381056 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {10391057 const vault = await import("./vault");10401058 await vault.createVault("rollback-pass-bbbbb");src/background/vault.ts+30 −18
@@ -50,6 +50,17 @@5050 // This coarsens ciphertext-length leakage into 1 KiB buckets.5151 const PAD_BLOCK = 1024;5252 53+// When duress is configured, BOTH slots are padded to this fixed ceiling so54+// their ciphertext lengths are identical and cannot reveal that a hidden decoy55+// vault exists (audit MEDIUM — plausible-deniability leak). 256 KiB comfortably56+// holds a very large vault (thousands of entries incl. passkeys). The decoy57+// slot can't be re-encrypted on every real-vault save (the real session has no58+// decoy key), so a fixed ceiling — rather than size-matching on each write — is59+// what keeps the two slots equal. A real vault whose plaintext exceeds this60+// would grow past the ceiling and re-expose the bit; that is an extreme,61+// documented edge well beyond normal use.62+const DURESS_SLOT_SIZE = 262_144;63+5364 let derivedKey: Uint8Array | null = null;5465 // 32-byte random root key, separate from the derivedKey used for vault encryption.5566 // The root key is wrapped (encrypted) under the recovery key derived from the@@ -367,7 +378,7 @@367378 newKey: Uint8Array,368379 slotA: StoredVault369380 ): Promise<void> {370- const plaintext = encodePayload(entries, duressConfigured);381+ const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);371382 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");372383 await writeSlot(SLOT_A, {373384 ...slotA,@@ -628,9 +639,11 @@628639 * whitespace after a complete document, so the padding is transparent on629640 * decode. Ciphertext length equals plaintext length plus a 16-byte tag.630641 */631-function padPlaintext(json: string): string {642+function padPlaintext(json: string, minBytes = 0): string {632643 const encoded = new TextEncoder().encode(json);633- const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);644+ const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);645+ // minBytes lets the duress path force a fixed ceiling so both slots match.646+ const target = Math.max(bucket, minBytes);634647 const padLen = target - encoded.length;635648 return json + " ".repeat(padLen);636649 }@@ -693,9 +706,9 @@693706 migratedFromLegacy: boolean;694707 };695708 696-function encodePayload(entries: VaultEntry[], duress: boolean): string {709+function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {697710 const envelope: VaultPayload = { entries, duressConfigured: duress };698- return padPlaintext(JSON.stringify(envelope));711+ return padPlaintext(JSON.stringify(envelope), minBytes);699712 }700713 701714 function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {@@ -929,7 +942,7 @@929942 await generateAndWrapRootKey();930943 rootKey = unhex(rawRootKey);931944 932- const plaintext = encodePayload(entries, duressConfigured);945+ const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);933946 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");934947 935948 // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate@@ -987,14 +1000,11 @@9871000 const decoySalt = generateSalt();9881001 const decoyKey = await deriveKey(duressPassword, decoySalt);9891002 try {990- // Pad the decoy plaintext to match slot A's ciphertext length (minus the991- // 16-byte tag) so encrypted sizes align exactly.992- const targetBytes = slotA.ciphertext.length / 2;993- let plaintext = encodePayload([], false);994- const encoded = new TextEncoder().encode(plaintext);995- if (encoded.length < targetBytes) {996- plaintext = plaintext + " ".repeat(targetBytes - encoded.length);997- }1003+ // Pad the decoy to the fixed duress ceiling. Slot A is re-encrypted to the1004+ // same ceiling by the persistSlotA() call below (duressConfigured is now1005+ // true), so both slots are exactly DURESS_SLOT_SIZE+tag at rest and their1006+ // lengths cannot reveal that duress is configured (audit MEDIUM).1007+ const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);9981008 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");9991009 await writeSlot(SLOT_B, {10001010 version: 1,@@ -1896,7 +1906,7 @@18961906 // are swallowed by takeVaultSnapshot — history must never block a save.18971907 await takeVaultSnapshot(reason);18981908 1899- const plaintext = encodePayload(entries, duressConfigured);1909+ const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);19001910 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19011911 19021912 // Preserve existing recovery escrow fields.@@ -1934,8 +1944,10 @@19341944 if (!slotB) throw new Error("missing slot B");19351945 19361946 // Decoy plaintext always reports duressConfigured=false — the decoy user1937- // sees a clean slate.1938- const plaintext = encodePayload(entries, false);1947+ // sees a clean slate. persistSlotB only runs in decoy mode, which only1948+ // exists once duress is configured, so pad to the fixed duress ceiling to1949+ // keep slot B's length equal to slot A's (audit MEDIUM — deniability).1950+ const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);19391951 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");19401952 19411953 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });@@ -2461,7 +2473,7 @@24612473 const newKey = await deriveKey(newPassword, newSalt);24622474 24632475 // Re-encrypt the in-memory entries with the new key2464- const plaintext = encodePayload(entries, duressConfigured);2476+ const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);24652477 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");24662478 24672479 // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey