Skip to content

Commit 8cfa65a

Browse files
committed
fix(node): consume iCaptcha proof jti so a proof can't be replayed
Addresses @jatmn P2 on #108: the verifier checked exp/level/sub but never consumed the proof's jti, so one solved challenge was a reusable bearer proof for that DID until expiry, covering every gated create_repo/register in the window. Now, in enforce mode, each verified proof's jti is recorded once and replays are rejected. - db: migration v7 adds icaptcha_consumed_proofs(jti PK, expires_at); new consume_proof_jti (INSERT ON CONFLICT DO NOTHING -> bool) and sweep_expired_proofs. DB-backed so it holds across restarts and instances. - icaptcha: ProofClaims carries jti; verify returns the claims; decide() stays pure and returns Allow | Reject | Consume{jti,exp}; check() is now async and spends the jti via the DB, rejecting replays (enforce only; shadow observes without consuming). - handlers: create_repo/register await check(&state.db, ...). - main: periodic sweep of expired proof rows alongside rate-limit cleanup. - tests: 12 pass incl. decide() yields Consume with the proof's jti. Author: Kevin Codex <kevin@gitlawb.com>
1 parent edfd0fa commit 8cfa65a

5 files changed

Lines changed: 136 additions & 28 deletions

File tree

‎crates/gitlawb-node/src/api/register.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ pub async fn register(
4040
Json(req): Json<RegisterRequest>,
4141
) -> Result<(StatusCode, Json<RegisterResponse>)> {
4242
// iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
43-
crate::icaptcha::check(&headers, &auth.0)?;
43+
crate::icaptcha::check(&state.db, &headers, &auth.0).await?;
4444

4545
// Parse and validate the DID
4646
let agent_did: Did = req

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ pub async fn create_repo(
6767
Json(req): Json<CreateRepoRequest>,
6868
) -> Result<(StatusCode, Json<RepoResponse>)> {
6969
// iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
70-
crate::icaptcha::check(&headers, &auth.0)?;
70+
crate::icaptcha::check(&state.db, &headers, &auth.0).await?;
7171

7272
// Sanitize name: alphanumeric, hyphens, underscores only
7373
if !req

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,21 @@ const MIGRATIONS: &[Migration] = &[
767767
"ALTER TABLE agents ADD COLUMN IF NOT EXISTS deactivated_at TEXT",
768768
],
769769
},
770+
Migration {
771+
version: 7,
772+
name: "icaptcha_consumed_proofs",
773+
stmts: &[
774+
// Single-use ledger for iCaptcha proof ids (jti). A proof may be
775+
// spent once per gated action; replays are rejected until the row
776+
// is swept after the proof's own expiry. `expires_at` is the
777+
// proof's unix-seconds exp, used for cleanup.
778+
r#"CREATE TABLE IF NOT EXISTS icaptcha_consumed_proofs (
779+
jti TEXT NOT NULL PRIMARY KEY,
780+
expires_at BIGINT NOT NULL
781+
)"#,
782+
"CREATE INDEX IF NOT EXISTS idx_icaptcha_consumed_expires ON icaptcha_consumed_proofs(expires_at)",
783+
],
784+
},
770785
];
771786

772787
// ── Repos ─────────────────────────────────────────────────────────────────────
@@ -1021,6 +1036,32 @@ impl Db {
10211036
Ok(())
10221037
}
10231038

1039+
/// Atomically consume an iCaptcha proof id (`jti`). Returns `Ok(true)` if it
1040+
/// was newly recorded (the proof may be used), `Ok(false)` if it was already
1041+
/// spent (a replay). `expires_at` is the proof's unix-seconds `exp`, kept so
1042+
/// the ledger row can be swept once the proof can no longer be valid.
1043+
pub async fn consume_proof_jti(&self, jti: &str, expires_at: i64) -> Result<bool> {
1044+
let result = sqlx::query(
1045+
"INSERT INTO icaptcha_consumed_proofs (jti, expires_at)
1046+
VALUES ($1, $2)
1047+
ON CONFLICT (jti) DO NOTHING",
1048+
)
1049+
.bind(jti)
1050+
.bind(expires_at)
1051+
.execute(&self.pool)
1052+
.await?;
1053+
Ok(result.rows_affected() > 0)
1054+
}
1055+
1056+
/// Delete consumed-proof rows whose proof has expired. Returns rows removed.
1057+
pub async fn sweep_expired_proofs(&self, now: i64) -> Result<u64> {
1058+
let result = sqlx::query("DELETE FROM icaptcha_consumed_proofs WHERE expires_at < $1")
1059+
.bind(now)
1060+
.execute(&self.pool)
1061+
.await?;
1062+
Ok(result.rows_affected())
1063+
}
1064+
10241065
pub async fn get_trust_score(&self, agent_did: &str) -> Result<f64> {
10251066
let row = sqlx::query("SELECT trust_score FROM agents WHERE did = $1")
10261067
.bind(agent_did)

‎crates/gitlawb-node/src/icaptcha.rs‎

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ struct Verifier {
8585

8686
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
8787

88-
#[derive(Deserialize)]
88+
#[derive(Deserialize, Debug)]
8989
struct 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
}

‎crates/gitlawb-node/src/main.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,15 +265,23 @@ async fn main() -> Result<()> {
265265
});
266266
}
267267

268-
// Periodic cleanup of expired rate limit entries
268+
// Periodic cleanup of expired rate limit entries + consumed-proof ledger
269269
{
270270
let rl = state.rate_limiter.clone();
271+
let db = state.db.clone();
271272
let mut shutdown_rx = state.subscribe_shutdown();
272273
tokio::spawn(async move {
273274
loop {
274275
tokio::select! {
275276
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
276277
rl.cleanup().await;
278+
let now = std::time::SystemTime::now()
279+
.duration_since(std::time::UNIX_EPOCH)
280+
.map(|d| d.as_secs() as i64)
281+
.unwrap_or(0);
282+
if let Err(e) = db.sweep_expired_proofs(now).await {
283+
tracing::warn!(err = %e, "failed to sweep expired iCaptcha proofs");
284+
}
277285
}
278286
_ = shutdown_rx.changed() => {
279287
if *shutdown_rx.borrow() {

0 commit comments

Comments
 (0)