@@ -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).
2822pub 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
3481impl 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.
135192pub 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