Key-slot cost parameters (m_cost/t_cost/p_cost) are parsed verbatim out of
the superblock's plaintext crypto header and handed to the KDF during
`open`. That happens before the format-version gate and before the
page-size gate, so nothing stands between the file's bytes and the
allocator.
The argon2 crate enforces no ceiling of its own: Params::MAX_M_COST and
MAX_T_COST are both u32::MAX, with a source comment stating no upper check
is needed. Its working buffer is an infallible
`vec![Block::default(); block_count()]` at 1 KiB per block, so allocation
failure aborts the process instead of returning an error. An m_cost of
u32::MAX is a 4 TiB request; a large t_cost is an unbounded hang. Both are
retried for each of up to 8 active slots.
Measured before the fix, with u32::MAX patched into slot 0 of an otherwise
valid database: the opening process reserved 4.4 TiB of address space and
had 14 GB resident and climbing before it was killed. Four bytes in a file.
Bounds the three parameters in `derive_kek`, which is the single point both
the open path and the create path route through, so a hostile slot and a
self-inflicted `Options::argon2_params` are both covered. The caps are
generous against real configurations — 256 MiB is 13x the OWASP baseline
this crate writes, and t/p of 16 are well above the recommended 2/1 — but
the open path must be bounded by something. The HKDF arm is deliberately
left unguarded: it never reads these fields.
On the open path an over-cap slot is skipped as non-matching and `open`
ends in the existing operational InvalidEncryptionKey, so a hostile file
now produces a typed error rather than a dead process.
The caps are re-exported from the crate root because exceeding them is a
hard failure that a caller tuning cost parameters needs to be able to see.
Fixes the issue behind draft advisory GHSA-2p8x-4r6r-8jpw.
The bug
Key-slot cost parameters are parsed verbatim out of the superblock's plaintext crypto header and handed to the KDF:
This runs at
recovery.rs:342, before the format-version gate (:384) and before the page-size gate (:406), so no other validation intervenes. Andargon2enforces no ceiling of its own:m_costis in KiB, sou32::MAXis a 4 TiB request that kills the host process rather than returning an error; a larget_costis an unbounded hang instead. A failed slot justcontinues, so the cost is paid for each of up to 8 active slots.Measured
An otherwise-valid encrypted database, with
u32::MAXpatched into slot 0'sm_costand the page checksum re-stamped. Opening it with the correct passphrase:Killed manually. Four bytes in a file. On any host smaller than this one it is an immediate OOM kill.
The fix
Bound the three parameters in
derive_kek, beforeParams::newand therefore before any allocation.derive_kekis chosen deliberately over the slot-parse site: it is the single point both paths route through — the open path (untrusted disk bytes) and the create path (Options::argon2_params). Guarding there also stops a caller from creating a database whose own parameters would make it unopenable. The HKDF arm is left unguarded on purpose: it never reads these fields, and HKDF-written slots leave them zero. That is pinned by a test.Caps: m_cost ≤ 256 MiB, t_cost ≤ 16, p_cost ≤ 16. Generous against real configurations — 256 MiB is 13x the OWASP baseline this crate writes (19 MiB), and t/p of 16 are well above the recommended 2/1. The specific numbers matter less than the fact that the open path is bounded at all; raising them is a one-line change.
On the open path an over-cap slot is now skipped as non-matching, so
openends in the existing operationalInvalidEncryptionKey— a typed error instead of a dead process.Compatibility
Worth a reviewer's eye: a database created with
Options::argon2_paramsabove these caps would no longer open. Nothing in-tree writes such values (the only params this crate writes are the OWASP defaults), and the caps are far above any realistic setting, but it is a behaviour change rather than a pure hardening. The caps are re-exported from the crate root so the ceiling is discoverable rather than something you hit by failing.Related rough edge, not addressed here: over-cap params on the create path surface as
InvalidEncryptionKeyvia the blanketFrom<CryptoError>, which is a confusing error for "your cost parameters are too large". Worth a distinct variant, but that is a wider error-taxonomy change than this fix should carry.Tests
Written before the fix and confirmed failing for the right reason:
derive_kek_rejects_argon2_params_above_the_cap— one KiB over the cap, deliberately notu32::MAX, so the pre-fix run fails rather than aborting the test runnerderive_kek_still_accepts_realistic_argon2_params— the OWASP default and the cap itself must still derive (passed before and after; pins that the guard is not over-tight)hkdf_ignores_argon2_params_entirely—u32::MAXparams withkdf_id=HKDFmust still succeedtampered_key_slot_cost_params_do_not_reach_the_allocator— end-to-end: build a real encrypted DB, patchu32::MAXinto every superblock slot's key slot 0, re-stamp the checksums, reopen. Pre-fix this is the 4.4 TiB run above; post-fix it returnsInvalidEncryptionKeyin under two seconds.cargo test685 passed / 0 failed ·cargo clippy --workspace -- -D warningsclean ·cargo fmt --checkclean.Note for reviewers
Branched from
mainrather than the local development lineage, which shares no ancestry with it. The files involved are byte-identical across the two, so this applies cleanly either way — but it needs porting to the dev lineage separately. Independent of #127; they touch different files.Found by the clean-slate deep review of 2026-07-29 (finding
SUPERBLOCK-RECOVERY-1).