Skip to content

RFC: container format v2 — Argon2id and chunked files #58

Description

@deanrie

Design only; no code. Written so the decision can be made on the tradeoffs rather than on an implementation that already exists. Builds on #43 (password normalization is settled before any new KDF), #47 (one crypto core for both files, so v2 lands in one place) and the fixture discipline in #46 (v2 ciphertexts would be pinned from day one). Closes SECURITY-AUDIT.md Low #1 if adopted.

Why a v2 at all

Two limits of v1 are format-level, not fixable in the UI:

  1. PBKDF2-SHA256 at 1,000,000 iterations is OWASP-compliant and GPU-friendly. A memory-hard KDF (Argon2id) is the "good → better" step the audit has carried as open since v2.2.0.
  2. 100 MB cap, because the whole file is one AES-GCM call held in memory three times over. Chunking removes the cap and allows streaming.

Both need a version byte; v1 already has one (IBTZ 0x01), so nothing about v0/v1 changes, ever.

Wire format

IBTZ 0x02
  kdf-id        1 byte    0x01 PBKDF2-SHA256 · 0x02 Argon2id
  kdf-params    variable  PBKDF2: iterations u32 · Argon2id: m_kib u32, t u8, p u8
  salt          16
  nonce-prefix  8         random per file
  chunk-size    u32       bytes of plaintext per chunk (1 MiB proposed)
  chunks        n ×  [ ciphertext || tag(16) ]

Per chunk i: nonce = nonce-prefix || u32(i); AAD = header || u32(i) || final-flag. The index in the AAD defeats reordering and replay of a chunk into another slot; the final flag defeats truncation (a stream that ends without a final = 1 chunk is corrupt); the whole header in the AAD binds the KDF choice and parameters, so an attacker cannot downgrade kdf-id on a stored file. 8 + 4 nonce bytes allow 2³² chunks per file — 4 PiB at 1 MiB — with no nonce reuse within a key, and the key is fresh per file anyway (random salt).

Last chunk may be short; empty files are one empty final chunk (a 16-byte tag), so "empty data" stops being an error.

Key derivation

The hard part: Argon2 in a single file with a hash-pinned CSP

Web Crypto has no Argon2. The options:

WASM Argon2id (e.g. the reference C via Emscripten, ~20 KB base64 in the file) Pure-JS Argon2id
64 MiB, t=3 on a 2020 laptop ≈ 0.4–0.8 s (argon2-browser's published figures) ≈ 4–8 s
same on a mid-range phone ≈ 1.5–3 s 15–30 s, and memory pressure
CSP needs 'wasm-unsafe-eval' added to script-src (it permits only WebAssembly compilation, not JS eval; the hash pins stay) no CSP change
Recovery tool needs the same WASM block (≈ 27 KB → ≈ 50 KB) needs the JS (≈ 27 KB → ≈ 35 KB)
Auditability a binary blob in a file whose selling point is "read it in a text editor" — would need a reproducible build recipe and its own hash in the README plain JS, readable

Both numbers are from public benchmarks and should be re-measured on your targets before deciding; mobile is the constraint.

Migration

  • v0 and v1 decrypt forever; scripts/crypto-regression.mts is unchanged, and fixtures:add pins v2 ciphertexts from the first release that writes them.
  • The app writes v2 by default, with a visible "compatible with older IttyBitz" switch that writes v1 (someone may need a file to open on a saved v3 copy).
  • The recovery tool must open v2 before any v2 file exists — ship the decrypt side first.
  • SECURITY-AUDIT.md Low Update README.md #1 → closed; README's "cryptography has never changed" sentence becomes "every version still decrypts".

Decision matrix

Option Closes audit Low #1 Lifts 100 MB cap CSP change File growth Risk
A stay on v1 no no none 0 none — status quo
B v2: Argon2id (WASM) + chunking yes yes 'wasm-unsafe-eval' ≈ +25 KB each file WASM provenance; mobile timing
C v2: chunking only, keep PBKDF2 (kdf-id 0x01) no yes none ≈ +1 KB smallest; leaves the KDF question open but representable
D v2: Argon2id (pure JS) + chunking yes yes none ≈ +8 KB too slow on phones at useful parameters

C is the smallest honest step: it fixes the format (chunking, AAD, HKDF key-file binding, parameters in the header) without taking the WASM decision, and leaves kdf-id 0x02 reserved so Argon2id can be switched on later as a parameter change, not a format change. B is the complete answer and the one the audit is asking for. A is defensible: PBKDF2 at 1M iterations is not broken, it is merely not the best available.

Happy to prototype whichever row is chosen behind the same regression gate; nothing here is a request to change the current format without that decision.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions