@@ -85,11 +85,13 @@ struct Verifier {
8585
8686static VERIFIER : OnceLock < Verifier > = OnceLock :: new ( ) ;
8787
88- #[ derive( Deserialize ) ]
88+ #[ derive( Deserialize , Debug ) ]
8989struct ProofClaims {
9090 sub : String ,
9191 level : u32 ,
9292 exp : i64 ,
93+ /// Unique proof id, consumed once so a proof cannot be replayed.
94+ jti : String ,
9395}
9496
9597#[ derive( Deserialize ) ]
@@ -167,49 +169,86 @@ pub async fn init() {
167169 } ) ;
168170}
169171
172+ /// Outcome of the synchronous, IO-free decision step.
173+ #[ derive( Debug ) ]
174+ enum Decision {
175+ /// Allow the request (off, shadow, inert/no-key, or verified non-enforcing).
176+ Allow ,
177+ /// Enforce mode and verification failed; reject with this reason.
178+ Reject ( String ) ,
179+ /// Enforce mode and the proof verified; the caller must consume this `jti`
180+ /// (and reject replays) before allowing.
181+ Consume { jti : String , exp : i64 } ,
182+ }
183+
170184/// Gate an authenticated request. `did` is the authenticated agent DID the proof
171- /// must belong to. Returns `Ok(())` to allow, `Err(Unauthorized)` to reject.
172- /// Honors the configured mode (Off/Shadow never reject).
173- pub fn check ( headers : & HeaderMap , did : & str ) -> Result < ( ) , AppError > {
185+ /// must belong to. In enforce mode a valid proof is consumed once (its `jti` is
186+ /// recorded in the DB) so it cannot be replayed across gated actions. Returns
187+ /// `Ok(())` to allow, `Err(Unauthorized)` to reject. Off/Shadow never reject.
188+ pub async fn check ( db : & crate :: db:: Db , headers : & HeaderMap , did : & str ) -> Result < ( ) , AppError > {
174189 let v = match VERIFIER . get ( ) {
175190 Some ( v) => v,
176191 None => return Ok ( ( ) ) , // not initialized -> inert
177192 } ;
178- decide ( v, headers, did, now_secs ( ) )
193+ match decide ( v, headers, did, now_secs ( ) ) {
194+ Decision :: Allow => Ok ( ( ) ) ,
195+ Decision :: Reject ( reason) => Err ( reject_error ( v, & reason) ) ,
196+ Decision :: Consume { jti, exp } => {
197+ // First use records the jti; a replay finds it already present.
198+ if db. consume_proof_jti ( & jti, exp) . await ? {
199+ Ok ( ( ) )
200+ } else {
201+ Err ( reject_error ( v, "proof already used (replay)" ) )
202+ }
203+ }
204+ }
205+ }
206+
207+ fn reject_error ( v : & Verifier , reason : & str ) -> AppError {
208+ AppError :: Unauthorized ( format ! (
209+ "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header." ,
210+ v. url, v. required_level, PROOF_HEADER
211+ ) )
179212}
180213
181- /// Mode-aware decision, separated from the global state for testability.
182- fn decide ( v : & Verifier , headers : & HeaderMap , did : & str , now : i64 ) -> Result < ( ) , AppError > {
214+ /// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
215+ /// is fully unit-testable. The caller performs jti consumption for `Consume`.
216+ fn decide ( v : & Verifier , headers : & HeaderMap , did : & str , now : i64 ) -> Decision {
183217 if v. mode == Mode :: Off {
184- return Ok ( ( ) ) ;
218+ return Decision :: Allow ;
185219 }
186220
187221 // Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable
188222 // at startup), stay inert rather than rejecting every request. The operator
189223 // already saw a startup warning. An iCaptcha hiccup must never break repo
190224 // creation or registration.
191225 if v. key . is_none ( ) {
192- return Ok ( ( ) ) ;
226+ return Decision :: Allow ;
193227 }
194228
195229 match verify ( v, headers, did, now) {
196- Ok ( ( ) ) => Ok ( ( ) ) ,
230+ Ok ( claims) => match v. mode {
231+ Mode :: Enforce => Decision :: Consume {
232+ jti : claims. jti ,
233+ exp : claims. exp ,
234+ } ,
235+ // Shadow/Off: never reject, and do not consume (observational only).
236+ _ => Decision :: Allow ,
237+ } ,
197238 Err ( reason) => match v. mode {
198239 Mode :: Shadow => {
199240 tracing:: warn!( did = %did, reason, "iCaptcha (shadow) would reject" ) ;
200- Ok ( ( ) )
241+ Decision :: Allow
201242 }
202- Mode :: Enforce => Err ( AppError :: Unauthorized ( format ! (
203- "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header." ,
204- v. url, v. required_level, PROOF_HEADER
205- ) ) ) ,
206- Mode :: Off => Ok ( ( ) ) ,
243+ Mode :: Enforce => Decision :: Reject ( reason) ,
244+ Mode :: Off => Decision :: Allow ,
207245 } ,
208246 }
209247}
210248
211- /// Core verification, separated for testability. `now` is unix seconds.
212- fn verify ( v : & Verifier , headers : & HeaderMap , did : & str , now : i64 ) -> Result < ( ) , String > {
249+ /// Core verification, separated for testability. Returns the validated claims.
250+ /// `now` is unix seconds.
251+ fn verify ( v : & Verifier , headers : & HeaderMap , did : & str , now : i64 ) -> Result < ProofClaims , String > {
213252 let key = v. key . as_ref ( ) . ok_or ( "verifier has no public key" ) ?;
214253 let proof = headers
215254 . get ( PROOF_HEADER )
@@ -241,7 +280,7 @@ fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(),
241280 if !crate :: api:: did_matches ( did, & claims. sub ) {
242281 return Err ( "proof subject does not match authenticated DID" . to_string ( ) ) ;
243282 }
244- Ok ( ( ) )
283+ Ok ( claims )
245284}
246285
247286#[ cfg( test) ]
@@ -329,7 +368,10 @@ mod tests {
329368 fn off_mode_allows_everything ( ) {
330369 let mut v = verifier ( 3 ) ;
331370 v. mode = Mode :: Off ;
332- assert ! ( decide( & v, & HeaderMap :: new( ) , SUB , IAT ) . is_ok( ) ) ;
371+ assert ! ( matches!(
372+ decide( & v, & HeaderMap :: new( ) , SUB , IAT ) ,
373+ Decision :: Allow
374+ ) ) ;
333375 }
334376
335377 #[ test]
@@ -341,25 +383,42 @@ mod tests {
341383 required_level : 3 ,
342384 key : None ,
343385 } ;
344- assert ! ( decide( & v, & HeaderMap :: new( ) , SUB , IAT ) . is_ok( ) ) ;
386+ assert ! ( matches!(
387+ decide( & v, & HeaderMap :: new( ) , SUB , IAT ) ,
388+ Decision :: Allow
389+ ) ) ;
345390 }
346391
347392 #[ test]
348393 fn enforce_with_key_rejects_missing_proof ( ) {
349394 let v = verifier ( 3 ) ;
350- assert ! ( decide( & v, & HeaderMap :: new( ) , SUB , IAT ) . is_err( ) ) ;
395+ assert ! ( matches!(
396+ decide( & v, & HeaderMap :: new( ) , SUB , IAT ) ,
397+ Decision :: Reject ( _)
398+ ) ) ;
351399 }
352400
353401 #[ test]
354402 fn shadow_allows_despite_bad_proof ( ) {
355403 let mut v = verifier ( 3 ) ;
356404 v. mode = Mode :: Shadow ;
357- assert ! ( decide( & v, & HeaderMap :: new( ) , SUB , IAT ) . is_ok( ) ) ;
405+ assert ! ( matches!(
406+ decide( & v, & HeaderMap :: new( ) , SUB , IAT ) ,
407+ Decision :: Allow
408+ ) ) ;
358409 }
359410
360411 #[ test]
361- fn enforce_accepts_valid_proof_via_decide ( ) {
412+ fn enforce_valid_proof_requires_consuming_its_jti ( ) {
413+ // A verified proof under enforce must yield Consume carrying the jti, so
414+ // the caller can spend it once and reject replays.
362415 let v = verifier ( 3 ) ;
363- assert ! ( decide( & v, & headers_with( PROOF ) , SUB , IAT ) . is_ok( ) ) ;
416+ match decide ( & v, & headers_with ( PROOF ) , SUB , IAT ) {
417+ Decision :: Consume { jti, exp } => {
418+ assert_eq ! ( jti, "4b5228a5bed7122dee9f47ff" ) ;
419+ assert_eq ! ( exp, 1782573151 ) ;
420+ }
421+ other => panic ! ( "expected Consume, got {other:?}" ) ,
422+ }
364423 }
365424}
0 commit comments