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
↓ peach-browser-0a5a9828.tar.gzatlas at this save
files changed2 files
src/crypto/peachPayload.test.ts+38 −0src/crypto/peachPayload.ts+49 −3src/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+});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");