0a5a9828b51d

pad .peach export to fixed size buckets (1KB/4KB/16KB/64KB/256KB) — prevents vault size inference from file size

time
2026-05-17 13:38 UTC · 5 months ago
machine
koh2
parent
e4ce0773cbbc
2 files src/crypto/peachPayload.test.ts+38 −0src/crypto/peachPayload.ts+49 −3
src/crypto/peachPayload.test.ts+38 −0
@@ -246,3 +246,41 @@246246     expect(out.entries).toHaveLength(100);247247   });248248 });249+250+describe("peachPayload bucket padding (v2 format)", () => {251+  // Single-entry vault is small — inner content << 1 KiB so it pads to 1 KiB.252+  // Salt(16) + Nonce(12) + ciphertext(1024) + tag(16) = 1068 outer bytes.253+  test("single-entry vault outer envelope is exactly 1068 bytes", async () => {254+    const env = await buildPayload({255+      entries: [loginEntry()],256+      settings: {},257+      passphrase: PASS,258+    });259+    expect(env.length).toBe(1068); // 16 + 12 + 1024 + 16260+  });261+262+  test("small vault and tiny vault produce the same outer envelope size", async () => {263+    const tiny = await buildPayload({ entries: [], settings: {}, passphrase: PASS });264+    const small = await buildPayload({265+      entries: [loginEntry(), loginEntry(), loginEntry()],266+      settings: {},267+      passphrase: PASS,268+    });269+    // Both fit in the 1 KiB bucket → both produce 1068-byte envelopes270+    expect(tiny.length).toBe(small.length);271+  });272+273+  test("round-trip through padding: entries survive buildPayload + parsePayload", async () => {274+    const entry = loginEntry({ title: "padding-test", password: "correcthorse" });275+    const env = await buildPayload({ entries: [entry], settings: {}, passphrase: PASS });276+    const out = await parsePayload(env, PASS);277+    expect(out.entries).toHaveLength(1);278+    expect(out.entries[0].title).toBe("padding-test");279+    expect(out.entries[0].password).toBe("correcthorse");280+  });281+282+  test("wrong passphrase still returns INVALID_PASSPHRASE (padding does not help attacker)", async () => {283+    const env = await buildPayload({ entries: [loginEntry()], settings: {}, passphrase: PASS });284+    await expect(parsePayload(env, "wrong")).rejects.toThrow("INVALID_PASSPHRASE");285+  });286+});

view file

src/crypto/peachPayload.ts+49 −3
@@ -54,6 +54,33 @@5454 export const NONCE_LEN = 12;5555 export const TAG_LEN = 16;5656 57+// Size buckets (bytes) for payload padding. Coarsens ciphertext-size leakage58+// so an attacker cannot infer vault size from file size. The bucket boundaries59+// are powers of 4 starting at 1 KiB.60+const SIZE_BUCKETS = [1024, 4096, 16384, 65536, 262144] as const;61+62+// Pad `data` to the next SIZE_BUCKET boundary. The returned Uint8Array is63+// zero-filled in the padding region — safe because it lives inside the AEAD.64+function padToNearestBucket(data: Uint8Array): Uint8Array {65+  const bucket = SIZE_BUCKETS.find((b) => b >= data.length);66+  let targetSize: number;67+  if (bucket !== undefined) {68+    targetSize = bucket;69+  } else {70+    // Larger than the largest bucket — round up to the next power of two.71+    targetSize = 262144;72+    while (targetSize < data.length) targetSize *= 2;73+  }74+  if (targetSize === data.length) return data;75+  const padded = new Uint8Array(targetSize);76+  padded.set(data);77+  return padded;78+}79+80+// v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC81+// and refuse gracefully; new clients detect v2 by this leading byte).82+const FORMAT_V2 = 0x02;83+5784 const ARGON2_MEMORY_KIB = 65536; // 64 MiB5885 const ARGON2_ITERATIONS = 3;5986 const ARGON2_PARALLELISM = 4;@@ -143,11 +170,20 @@143170   const checksum = await blake3Truncated(body);144171   const inner = concat([body, checksum]);145172 173+  // v2 format: [FORMAT_V2(1)][inner_len(4 LE)][inner][zero padding to bucket]174+  // Old clients see FORMAT_V2 (0x02) where they expect PCHS magic (0x50) and175+  // fail with BAD_MAGIC — an honest "can't read this" rather than a silent176+  // wrong-password failure.177+  const header5 = new Uint8Array(5);178+  header5[0] = FORMAT_V2;179+  new DataView(header5.buffer).setUint32(1, inner.length, true);180+  const wrapped = padToNearestBucket(concat([header5, inner]));181+146182   const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));147183   const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));148184   const key = await deriveKey(input.passphrase, salt);149185   try {150-    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, inner);186+    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);151187     return concat([salt, nonce, ciphertext, tag]);152188   } finally {153189     key.fill(0);@@ -174,13 +210,23 @@174210   const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);175211 176212   const key = await deriveKey(passphrase, salt);177-  let inner: Uint8Array;213+  let decrypted: Uint8Array;178214   try {179-    inner = await aesGcmDecrypt(key, nonce, ciphertext, tag);215+    decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);180216   } catch {181217     throw new Error("INVALID_PASSPHRASE");182218   } finally {183219     key.fill(0);220+  }221+222+  // Detect v2 padded format: leading byte FORMAT_V2 followed by 4-byte inner length.223+  let inner: Uint8Array;224+  if (decrypted.length >= 5 && decrypted[0] === FORMAT_V2) {225+    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);226+    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");227+    inner = decrypted.subarray(5, 5 + innerLen);228+  } else {229+    inner = decrypted; // v1 format — no length prefix, no padding184230   }185231 186232   if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");

view file