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
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.test.ts+18 −0src/background/vault.ts+30 −18
manifest.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": {

view file

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",

view file

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": {

view file

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");

view file

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

view file