Skip to content

Commit adbc7d2

Browse files
committed
encryption: make the pre-auth cost ceiling a real budget, not a smaller roof
Codex P1 on the first cut, and it was right. A ceiling of 1 GiB and 64 passes was chosen as "below Argon2's 4 TiB maximum", which is a rounding error rather than a limit: 1 GiB with 64 passes, reached before the header is authenticated, is just as fatal on a browser tab or a small container, and a few concurrent requests carrying it exhaust the host. The abort was closed and the denial of service left open. CostLimits makes the budget the caller's, explicitly: DEFAULT 128 MiB / 4 passes / 2 lanes — twice the memory and one pass more than the heaviest shipped preset, so a future cost bump still opens old and new blobs SHIPPED_PRESETS_ONLY 64 MiB / 3 / 1 — exactly what this crate emits, for a service that mints every blob it opens open_within / derive_key_within take it; open / derive_key use DEFAULT. Measured, release, this box: the worst header DEFAULT admits costs 414 ms and 128 MiB. That number is printed by an #[ignore]d test rather than asserted from a guess. The bit-flip sweep fell from 13.5 s to 2.3 s once the tighter cap began refusing flips it used to honour — the sweep had been quietly running multi-hundred-MiB derivations, which is the same finding one layer down. Not taken: an allowlist of known profiles. It breaks cost bumps in the other direction — a reader shipped before the writer would reject the new profile — and forward compatibility is exactly what the self-describing header exists for. 31 tests green, clippy clean. Generated by [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mwq1QKpw4zRd6oaGRoJhF2
1 parent 5b91586 commit adbc7d2

3 files changed

Lines changed: 196 additions & 38 deletions

File tree

.claude/blackboard.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -848,11 +848,28 @@ process reserves. Tamper detection works exactly as designed and the
848848
process still dies before reaching it. *Authenticated-but-only-later is not
849849
the same as trusted.*
850850

851-
`KdfParams::validate()` now gates m/t/p against a policy ceiling (1 GiB,
852-
64 passes, 64 lanes) in `derive_key` and in `decode_header`, i.e. before
853-
any allocation. The tests assert the refusal is **cheap** — an expensive
854-
rejection is itself the attack.
855-
856-
**Loose end:** the ceiling is a crate-level policy constant, not
857-
configurable. If a caller ever legitimately needs >1 GiB, it becomes a
858-
builder parameter — but the default must stay bounded.
851+
`KdfParams::validate()` now gates m/t/p **before any allocation**, in
852+
`derive_key` and in `decode_header`. The tests assert the refusal is
853+
**cheap** — an expensive rejection is itself the attack.
854+
855+
**Codex P1 on the first cut, and it was right:** the initial ceiling
856+
(1 GiB / 64 passes) was chosen as "below Argon2's 4 TiB roof", which is a
857+
rounding error, not a limit — 1 GiB × 64 passes pre-authentication is
858+
equally fatal on a browser tab or a small container, and a few concurrent
859+
requests exhaust the host. Replaced by `CostLimits`, a caller-supplied
860+
budget: `DEFAULT` = 128 MiB / 4 / 2 (twice the memory and one pass more
861+
than the heaviest shipped preset, so a cost bump still opens old and new
862+
blobs), `SHIPPED_PRESETS_ONLY` = exactly 64 MiB / 3 / 1 for services that
863+
mint every blob they open. `open_within` / `derive_key_within` take the
864+
budget explicitly.
865+
866+
Measured worst case the default admits: **414 ms, 128 MiB** (release, this
867+
box; the `#[ignore]`d `worst_admitted_cost_is_within_the_documented_budget`
868+
prints it). The bit-flip sweep dropped 13.5 s → 2.3 s once the tighter cap
869+
started refusing the flips it used to honour — the sweep had itself been
870+
running multi-hundred-MiB derivations.
871+
872+
**Not done — an allowlist of known profiles** (Codex's alternative) would
873+
break cost bumps in the other direction: a reader shipped before the writer
874+
would reject the new profile. A bounded budget keeps the forward
875+
compatibility the header format exists for.

crates/encryption/src/envelope.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
//! note there. Authenticated-but-only-later is not the same as trusted.
3434
3535
use crate::aead::{self, NONCE_LEN};
36-
pub use crate::kdf::KdfParams;
3736
use crate::kdf::{self, KdfError};
37+
pub use crate::kdf::{CostLimits, KdfParams};
3838

3939
/// Envelope magic: "ADAC" (Ada crypto).
4040
pub const MAGIC: [u8; 4] = *b"ADAC";
@@ -101,11 +101,22 @@ pub fn seal(password: &[u8], plaintext: &[u8], params: &KdfParams) -> Result<Vec
101101
/// Open a sealed envelope with `password`. The KDF parameters are read
102102
/// from the (authenticated) header, so cost bumps never orphan old blobs.
103103
pub fn open(password: &[u8], blob: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
104-
let (params, salt, nonce) = decode_header(blob)?;
104+
open_within(password, blob, &CostLimits::DEFAULT)
105+
}
106+
107+
/// Open a sealed envelope, checking the header's cost parameters against a
108+
/// caller-supplied budget instead of [`CostLimits::DEFAULT`].
109+
///
110+
/// The blob is untrusted input and its cost fields are acted upon before they
111+
/// are authenticated (see the module note), so this budget is the only thing
112+
/// standing between a forged header and the allocator. Tighten it on any
113+
/// service that opens blobs it did not mint.
114+
pub fn open_within(password: &[u8], blob: &[u8], limits: &CostLimits) -> Result<Vec<u8>, EnvelopeError> {
115+
let (params, salt, nonce) = decode_header_within(blob, limits)?;
105116
let header = &blob[..HEADER_LEN];
106117
let ciphertext = &blob[HEADER_LEN..];
107118

108-
let key = kdf::derive_key(password, &salt, &params).map_err(EnvelopeError::Kdf)?;
119+
let key = kdf::derive_key_within(password, &salt, &params, limits).map_err(EnvelopeError::Kdf)?;
109120
aead::open_with_key(key.as_bytes(), &nonce, header, ciphertext).map_err(|_| EnvelopeError::Decrypt)
110121
}
111122

@@ -121,7 +132,9 @@ fn encode_header(params: &KdfParams, salt: &[u8; SALT_LEN], nonce: &[u8; NONCE_L
121132
h
122133
}
123134

124-
fn decode_header(blob: &[u8]) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_LEN]), EnvelopeError> {
135+
fn decode_header_within(
136+
blob: &[u8], limits: &CostLimits,
137+
) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_LEN]), EnvelopeError> {
125138
if blob.len() < HEADER_LEN || blob[0..4] != MAGIC {
126139
return Err(EnvelopeError::Malformed);
127140
}
@@ -135,7 +148,7 @@ fn decode_header(blob: &[u8]) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_L
135148
p_cost: le_u32(13),
136149
};
137150
// Refused here, before the derivation this header would otherwise drive.
138-
params.validate().map_err(EnvelopeError::Kdf)?;
151+
params.validate_within(limits).map_err(EnvelopeError::Kdf)?;
139152
let mut salt = [0u8; SALT_LEN];
140153
salt.copy_from_slice(&blob[17..17 + SALT_LEN]);
141154
let mut nonce = [0u8; NONCE_LEN];
@@ -222,11 +235,28 @@ mod tests {
222235
);
223236
}
224237

238+
/// A budget below what the blob was sealed with refuses it — the knob is
239+
/// real, and a service can shrink its pre-authentication surface to the
240+
/// presets it actually mints.
241+
#[test]
242+
fn a_tighter_budget_refuses_a_blob_it_would_otherwise_open() {
243+
let blob = seal(b"pw", b"s", &KdfParams::INTERACTIVE).unwrap();
244+
assert_eq!(open(b"pw", &blob).unwrap(), b"s");
245+
let tiny = CostLimits {
246+
max_m_cost_kib: 1024,
247+
..CostLimits::DEFAULT
248+
};
249+
assert_eq!(
250+
open_within(b"pw", &blob, &tiny).unwrap_err(),
251+
EnvelopeError::Kdf(crate::kdf::KdfError::CostOutOfPolicy)
252+
);
253+
}
254+
225255
/// The refusal is a header check, so it must not depend on the password.
226256
#[test]
227257
fn an_absurd_cost_header_is_refused_before_the_password_matters() {
228258
let mut blob = seal(b"pw", b"s", &FAST).unwrap();
229-
blob[8] = 0xFF; // top byte of m_cost_kib → ~4 TiB
259+
blob[8] = 0xFF; // top byte of m_cost_kib → far past any budget
230260
assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::Kdf(crate::kdf::KdfError::CostOutOfPolicy));
231261
assert_eq!(
232262
open(b"wrong-password-entirely", &blob).unwrap_err(),

crates/encryption/src/kdf.rs

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,70 @@ pub struct KdfParams {
1818
pub p_cost: u32,
1919
}
2020

21-
/// Largest memory cost this crate will run: 1 GiB.
22-
///
23-
/// Argon2 itself permits up to `u32::MAX` KiB — 4 TiB. Nothing legitimate
24-
/// asks for that, and honouring it costs more than refusing it: see
25-
/// [`KdfParams::validate`].
26-
pub const MAX_M_COST_KIB: u32 = 1024 * 1024;
2721
/// Smallest memory cost Argon2id accepts (8 KiB per lane).
2822
pub const MIN_M_COST_KIB: u32 = 8;
29-
/// Largest pass count this crate will run.
30-
pub const MAX_T_COST: u32 = 64;
31-
/// Largest lane count this crate will run.
32-
pub const MAX_P_COST: u32 = 64;
23+
24+
/// The resource budget a caller is willing to spend on **unauthenticated**
25+
/// parameters.
26+
///
27+
/// Argon2's own maximum is `u32::MAX` KiB — 4 TiB. Refusing only that is
28+
/// not a limit, it is a rounding error: a cost of 1 GiB × 64 passes is
29+
/// equally fatal on a browser tab or a small container, and a handful of
30+
/// concurrent requests carrying it exhausts the host without ever being
31+
/// authenticated. The ceiling therefore has to be a budget the platform can
32+
/// actually absorb, not merely a number below Argon2's roof.
33+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34+
pub struct CostLimits {
35+
/// Largest accepted memory cost, in KiB.
36+
pub max_m_cost_kib: u32,
37+
/// Largest accepted pass count.
38+
pub max_t_cost: u32,
39+
/// Largest accepted lane count.
40+
pub max_p_cost: u32,
41+
}
42+
43+
impl CostLimits {
44+
/// 128 MiB, 4 passes, 2 lanes.
45+
///
46+
/// Twice the memory and one pass more than [`KdfParams::DEFAULT`], the
47+
/// most expensive preset this crate emits — enough headroom for a future
48+
/// cost bump to open old and new blobs alike, while keeping the worst
49+
/// case one attacker-supplied header can buy at roughly a third of a
50+
/// second and 128 MiB.
51+
///
52+
/// A service that opens blobs from the public internet should pass
53+
/// something tighter (and rate-limit); a batch tool on a big host can
54+
/// pass something looser. The default errs toward the smallest plausible
55+
/// deployment, because that is the one that falls over.
56+
pub const DEFAULT: CostLimits = CostLimits {
57+
max_m_cost_kib: 128 * 1024,
58+
max_t_cost: 4,
59+
max_p_cost: 2,
60+
};
61+
62+
/// Exactly the presets this crate ships ([`KdfParams::DEFAULT`] and
63+
/// [`KdfParams::INTERACTIVE`]), with no headroom at all.
64+
///
65+
/// For a deployment that mints every blob it opens and wants the
66+
/// narrowest possible pre-authentication surface. The trade is explicit:
67+
/// raising a cost later means shipping the reader before the writer.
68+
pub const SHIPPED_PRESETS_ONLY: CostLimits = CostLimits {
69+
max_m_cost_kib: 64 * 1024,
70+
max_t_cost: 3,
71+
max_p_cost: 1,
72+
};
73+
}
74+
75+
impl Default for CostLimits {
76+
fn default() -> Self {
77+
Self::DEFAULT
78+
}
79+
}
3380

3481
impl KdfParams {
35-
/// Check the cost parameters against this crate's policy ceiling
36-
/// **before** any memory is reserved.
82+
/// Check the cost parameters against [`CostLimits::DEFAULT`] **before**
83+
/// any memory is reserved. See [`KdfParams::validate_within`] to supply
84+
/// a budget of your own.
3785
///
3886
/// The ceiling exists because of where these parameters come from. In
3987
/// [`crate::envelope`] they are read out of the blob's header, and the
@@ -49,22 +97,31 @@ impl KdfParams {
4997
/// Rejecting must therefore be *cheap* — a comparison, not an attempt.
5098
///
5199
/// ```
52-
/// use encryption::kdf::{KdfParams, KdfError, MAX_M_COST_KIB};
100+
/// use encryption::kdf::{CostLimits, KdfParams, KdfError};
53101
///
54102
/// assert!(KdfParams::INTERACTIVE.validate().is_ok());
55103
///
56-
/// let absurd = KdfParams { m_cost_kib: u32::MAX, ..KdfParams::INTERACTIVE };
57-
/// assert_eq!(absurd.validate(), Err(KdfError::CostOutOfPolicy));
58-
/// assert!(u32::MAX > MAX_M_COST_KIB);
104+
/// // Not merely absurd values — anything past the platform budget.
105+
/// let heavy = KdfParams { m_cost_kib: 512 * 1024, ..KdfParams::DEFAULT };
106+
/// assert_eq!(heavy.validate(), Err(KdfError::CostOutOfPolicy));
107+
///
108+
/// // And a caller with a different budget says so explicitly.
109+
/// assert!(heavy.validate_within(&CostLimits { max_m_cost_kib: 1024 * 1024, ..CostLimits::DEFAULT }).is_ok());
59110
/// ```
60111
pub const fn validate(&self) -> Result<(), KdfError> {
61-
if self.m_cost_kib < MIN_M_COST_KIB || self.m_cost_kib > MAX_M_COST_KIB {
112+
self.validate_within(&CostLimits::DEFAULT)
113+
}
114+
115+
/// Check the cost parameters against a caller-supplied budget, before any
116+
/// memory is reserved.
117+
pub const fn validate_within(&self, limits: &CostLimits) -> Result<(), KdfError> {
118+
if self.m_cost_kib < MIN_M_COST_KIB || self.m_cost_kib > limits.max_m_cost_kib {
62119
return Err(KdfError::CostOutOfPolicy);
63120
}
64-
if self.t_cost == 0 || self.t_cost > MAX_T_COST {
121+
if self.t_cost == 0 || self.t_cost > limits.max_t_cost {
65122
return Err(KdfError::CostOutOfPolicy);
66123
}
67-
if self.p_cost == 0 || self.p_cost > MAX_P_COST {
124+
if self.p_cost == 0 || self.p_cost > limits.max_p_cost {
68125
return Err(KdfError::CostOutOfPolicy);
69126
}
70127
Ok(())
@@ -133,8 +190,16 @@ impl std::error::Error for KdfError {}
133190
/// Derive a 256-bit key from `password` and a 16-byte `salt` with
134191
/// Argon2id (v1.3). Deterministic: same inputs → same key.
135192
pub fn derive_key(password: &[u8], salt: &[u8; 16], params: &KdfParams) -> Result<DerivedKey, KdfError> {
193+
derive_key_within(password, salt, params, &CostLimits::DEFAULT)
194+
}
195+
196+
/// Derive a key, checking `params` against a caller-supplied budget rather
197+
/// than [`CostLimits::DEFAULT`].
198+
pub fn derive_key_within(
199+
password: &[u8], salt: &[u8; 16], params: &KdfParams, limits: &CostLimits,
200+
) -> Result<DerivedKey, KdfError> {
136201
// Before Argon2 sees them, and therefore before anything is allocated.
137-
params.validate()?;
202+
params.validate_within(limits)?;
138203
let argon_params =
139204
Params::new(params.m_cost_kib, params.t_cost, params.p_cost, Some(32)).map_err(|_| KdfError::InvalidParams)?;
140205
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params);
@@ -184,16 +249,17 @@ mod tests {
184249
/// ceiling were missing, this test would not fail, it would abort the
185250
/// whole test process trying to reserve 4 TiB.
186251
#[test]
187-
fn an_absurd_memory_cost_is_refused_without_attempting_it() {
252+
fn a_cost_beyond_the_platform_budget_is_refused_without_attempting_it() {
253+
// Not u32::MAX — a value Argon2 would happily accept and run.
188254
let absurd = KdfParams {
189-
m_cost_kib: u32::MAX,
190-
t_cost: 1,
255+
m_cost_kib: 512 * 1024,
256+
t_cost: 4,
191257
p_cost: 1,
192258
};
193259
let started = std::time::Instant::now();
194260
match derive_key(b"pw", &[0u8; 16], &absurd) {
195261
Err(e) => assert_eq!(e, KdfError::CostOutOfPolicy),
196-
Ok(_) => panic!("4 TiB of memory cost must be refused"),
262+
Ok(_) => panic!("512 MiB of unauthenticated memory cost must be refused"),
197263
}
198264
assert!(
199265
started.elapsed() < std::time::Duration::from_millis(50),
@@ -206,6 +272,51 @@ mod tests {
206272
assert_eq!(KdfParams::DEFAULT.validate(), Ok(()));
207273
assert_eq!(KdfParams::INTERACTIVE.validate(), Ok(()));
208274
assert_eq!(TEST_PARAMS.validate(), Ok(()));
275+
// …including under the tightest limits this crate offers.
276+
let only = CostLimits::SHIPPED_PRESETS_ONLY;
277+
assert_eq!(KdfParams::DEFAULT.validate_within(&only), Ok(()));
278+
assert_eq!(KdfParams::INTERACTIVE.validate_within(&only), Ok(()));
279+
}
280+
281+
/// The budget is a real bound on work, not a slogan. This measures what
282+
/// the worst header the default limits admit actually costs, so the
283+
/// number in the docs is observed rather than asserted.
284+
#[test]
285+
#[ignore = "measures the worst admitted cost; run explicitly"]
286+
fn worst_admitted_cost_is_within_the_documented_budget() {
287+
let l = CostLimits::DEFAULT;
288+
let worst = KdfParams {
289+
m_cost_kib: l.max_m_cost_kib,
290+
t_cost: l.max_t_cost,
291+
p_cost: l.max_p_cost,
292+
};
293+
let started = std::time::Instant::now();
294+
assert!(derive_key(b"pw", &[0u8; 16], &worst).is_ok());
295+
let took = started.elapsed();
296+
println!(
297+
"worst admitted: {} MiB x {} passes x {} lanes -> {:?}",
298+
l.max_m_cost_kib / 1024,
299+
l.max_t_cost,
300+
l.max_p_cost,
301+
took
302+
);
303+
assert!(took < std::time::Duration::from_secs(2), "budget too generous: {took:?}");
304+
}
305+
306+
/// A caller that knows its host can afford more says so, explicitly.
307+
#[test]
308+
fn a_wider_budget_is_opt_in_not_the_default() {
309+
let heavy = KdfParams {
310+
m_cost_kib: 512 * 1024,
311+
t_cost: 1,
312+
p_cost: 1,
313+
};
314+
assert_eq!(heavy.validate(), Err(KdfError::CostOutOfPolicy));
315+
let wide = CostLimits {
316+
max_m_cost_kib: 1024 * 1024,
317+
..CostLimits::DEFAULT
318+
};
319+
assert_eq!(heavy.validate_within(&wide), Ok(()));
209320
}
210321

211322
#[test]

0 commit comments

Comments
 (0)