From d53b5e1c6d421e4996e1c00a09aa1a12bf0c1951 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sat, 27 Jun 2026 23:13:09 +0800 Subject: [PATCH 1/6] feat(node): iCaptcha proof-of-intelligence gate on create_repo + register Add an optional gate that requires callers to present an iCaptcha proof (an Ed25519-signed token from icaptcha.gitlawb.com, earned by solving an escalating challenge) on the two spam-prone endpoints. Proofs are verified OFFLINE with ed25519-dalek against the published public key, and bound to the authenticated agent DID so they cannot be shared. Backward-compatible by default: ICAPTCHA_MODE is `off` unless set, so deploying changes nothing. `shadow` verifies and logs would-be rejections but always allows; `enforce` rejects requests lacking a valid, strong-enough proof. If the public key cannot be loaded at startup the gate stays inert (fail safe) so an iCaptcha outage can never break repo creation or registration. - New src/icaptcha.rs: env-driven verifier (OnceLock), /v1/pubkey fetch at startup or ICAPTCHA_PUBKEY override, proof parse + signature + expiry + level + DID-binding checks; mode-aware decide() split out for testing. - Wired into create_repo and register (X-ICaptcha-Proof header). git push (receive-pack) is intentionally NOT gated. - 11 unit tests incl. verification of a real captured proof (JS-signed, Rust-verified), tamper/expiry/wrong-DID/level/missing-header rejections, and the off/shadow/enforce/fail-safe mode matrix. Author: Kevin Codex --- .env.example | 16 ++ Cargo.lock | 1 + crates/gitlawb-node/Cargo.toml | 1 + crates/gitlawb-node/src/api/register.rs | 4 + crates/gitlawb-node/src/api/repos.rs | 4 + crates/gitlawb-node/src/icaptcha.rs | 327 ++++++++++++++++++++++++ crates/gitlawb-node/src/main.rs | 4 + 7 files changed, 357 insertions(+) create mode 100644 crates/gitlawb-node/src/icaptcha.rs diff --git a/.env.example b/.env.example index cd5b5aa..2c44710 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,19 @@ GITLAWB_MAX_PACK_BYTES=2147483648 # ── Sync ───────────────────────────────────────────────────────────────── # Enable automatic background sync from known peers GITLAWB_AUTO_SYNC=false + +# ── iCaptcha proof-of-intelligence gate ─────────────────────────────────── +# Optional gate on create_repo + register: require callers to present an +# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com. +# Default off = fully inert (no behavior change). +# off - gate disabled (default) +# shadow - verify + log would-be rejections, but always allow +# enforce - reject requests without a valid, strong-enough proof +ICAPTCHA_MODE=off +# Base URL of the iCaptcha service. +ICAPTCHA_URL=https://icaptcha.gitlawb.com +# Optional base64url Ed25519 public key override; if unset it is fetched from +# ICAPTCHA_URL/v1/pubkey at startup. +ICAPTCHA_PUBKEY= +# Minimum proof difficulty level required to pass. +ICAPTCHA_REQUIRED_LEVEL=3 diff --git a/Cargo.lock b/Cargo.lock index 2866a23..237fed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3365,6 +3365,7 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", + "base64", "bytes", "chrono", "cid", diff --git a/crates/gitlawb-node/Cargo.toml b/crates/gitlawb-node/Cargo.toml index 881d4bd..aff0963 100644 --- a/crates/gitlawb-node/Cargo.toml +++ b/crates/gitlawb-node/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] gitlawb-core = { path = "../gitlawb-core" } ed25519-dalek = { workspace = true } +base64 = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/gitlawb-node/src/api/register.rs b/crates/gitlawb-node/src/api/register.rs index eb199b1..036c7b5 100644 --- a/crates/gitlawb-node/src/api/register.rs +++ b/crates/gitlawb-node/src/api/register.rs @@ -36,8 +36,12 @@ pub struct RegisterResponse { pub async fn register( State(state): State, axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json)> { + // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). + crate::icaptcha::check(&headers, &auth.0)?; + // Parse and validate the DID let agent_did: Did = req .did diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 2065438..c8cfbd1 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -139,8 +139,12 @@ pub struct InfoRefsQuery { pub async fn create_repo( State(state): State, Extension(auth): Extension, + headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json)> { + // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). + crate::icaptcha::check(&headers, &auth.0)?; + // Sanitize name: alphanumeric, hyphens, underscores only if !req .name diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs new file mode 100644 index 0000000..4d8e62a --- /dev/null +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -0,0 +1,327 @@ +//! iCaptcha proof-of-intelligence gate. +//! +//! Spam-prone endpoints (repo creation, agent registration) can require the +//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by +//! after the caller solves an escalating +//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha) +//! using its published public key, and bind each proof to the authenticated +//! agent DID so a proof cannot be shared between identities. +//! +//! Behaviour is controlled by `ICAPTCHA_MODE`: +//! * `off` (default) — gate is inert, nothing is checked. +//! * `shadow` — verify and log would-be rejections, but always allow. +//! * `enforce` — reject requests without a valid, sufficiently-strong proof. +//! +//! Config (env): +//! ICAPTCHA_MODE off | shadow | enforce (default off) +//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com) +//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey) +//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3) + +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::http::HeaderMap; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::Deserialize; + +use crate::error::AppError; + +const PROOF_HEADER: &str = "x-icaptcha-proof"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + Off, + Shadow, + Enforce, +} + +impl Mode { + fn as_str(self) -> &'static str { + match self { + Mode::Off => "off", + Mode::Shadow => "shadow", + Mode::Enforce => "enforce", + } + } +} + +fn parse_mode(s: &str) -> Mode { + match s.trim().to_ascii_lowercase().as_str() { + "enforce" => Mode::Enforce, + "shadow" => Mode::Shadow, + _ => Mode::Off, + } +} + +struct Verifier { + mode: Mode, + url: String, + required_level: u32, + key: Option, +} + +static VERIFIER: OnceLock = OnceLock::new(); + +#[derive(Deserialize)] +struct ProofClaims { + sub: String, + level: u32, + exp: i64, +} + +#[derive(Deserialize)] +struct Jwk { + x: String, +} + +#[derive(Deserialize)] +struct Jwks { + keys: Vec, +} + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn decode_key(b64url: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?; + let arr: [u8; 32] = bytes.try_into().ok()?; + VerifyingKey::from_bytes(&arr).ok() +} + +async fn fetch_key(url: &str) -> Option { + let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/')); + let jwks: Jwks = reqwest::get(&endpoint).await.ok()?.json().await.ok()?; + decode_key(&jwks.keys.first()?.x) +} + +/// Initialize the gate from the environment. Call once at startup. Never panics; +/// if the gate is active but no key can be loaded, it stays inert and warns. +pub async fn init() { + let mode = parse_mode(&std::env::var("ICAPTCHA_MODE").unwrap_or_default()); + let url = std::env::var("ICAPTCHA_URL") + .unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string()); + let required_level = std::env::var("ICAPTCHA_REQUIRED_LEVEL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3); + + let key = if mode == Mode::Off { + None + } else { + match std::env::var("ICAPTCHA_PUBKEY") { + Ok(b64) if !b64.is_empty() => decode_key(&b64), + _ => fetch_key(&url).await, + } + }; + + if mode != Mode::Off { + if key.is_some() { + tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active"); + } else { + tracing::warn!( + mode = mode.as_str(), + "iCaptcha gate enabled but no public key could be loaded; staying inert" + ); + } + } + + let _ = VERIFIER.set(Verifier { + mode, + url, + required_level, + key, + }); +} + +/// Gate an authenticated request. `did` is the authenticated agent DID the proof +/// must belong to. Returns `Ok(())` to allow, `Err(Unauthorized)` to reject. +/// Honors the configured mode (Off/Shadow never reject). +pub fn check(headers: &HeaderMap, did: &str) -> Result<(), AppError> { + let v = match VERIFIER.get() { + Some(v) => v, + None => return Ok(()), // not initialized -> inert + }; + decide(v, headers, did, now_secs()) +} + +/// Mode-aware decision, separated from the global state for testability. +fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), AppError> { + if v.mode == Mode::Off { + return Ok(()); + } + + // Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable + // at startup), stay inert rather than rejecting every request. The operator + // already saw a startup warning. An iCaptcha hiccup must never break repo + // creation or registration. + if v.key.is_none() { + return Ok(()); + } + + match verify(v, headers, did, now) { + Ok(()) => Ok(()), + Err(reason) => match v.mode { + Mode::Shadow => { + tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject"); + Ok(()) + } + Mode::Enforce => Err(AppError::Unauthorized(format!( + "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", + v.url, v.required_level, PROOF_HEADER + ))), + Mode::Off => Ok(()), + }, + } +} + +/// Core verification, separated for testability. `now` is unix seconds. +fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), String> { + let key = v.key.as_ref().ok_or("verifier has no public key")?; + let proof = headers + .get(PROOF_HEADER) + .and_then(|h| h.to_str().ok()) + .ok_or("missing proof header")?; + + let (payload, sig_b64) = proof.split_once('.').ok_or("malformed proof")?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| "bad signature encoding")?; + let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?; + key.verify_strict(payload.as_bytes(), &sig) + .map_err(|_| "signature verification failed")?; + + let claims_bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| "bad payload encoding")?; + let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?; + + if claims.exp < now { + return Err("proof expired".to_string()); + } + if claims.level < v.required_level { + return Err(format!( + "level {} below required {}", + claims.level, v.required_level + )); + } + if !crate::api::did_matches(did, &claims.sub) { + return Err("proof subject does not match authenticated DID".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real values captured from https://icaptcha.gitlawb.com (a live proof). + const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8"; + const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA"; + const SUB: &str = "did:key:ztest"; + const IAT: i64 = 1782572851; // within the proof's validity window + + fn verifier(level: u32) -> Verifier { + Verifier { + mode: Mode::Enforce, + url: "https://icaptcha.gitlawb.com".to_string(), + required_level: level, + key: decode_key(PUBKEY_X), + } + } + + fn headers_with(proof: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(PROOF_HEADER, proof.parse().unwrap()); + h + } + + #[test] + fn accepts_a_real_proof() { + let v = verifier(3); + assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok()); + } + + #[test] + fn rejects_expired_proof() { + let v = verifier(3); + let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err(); + assert!(err.contains("expired"), "{err}"); + } + + #[test] + fn rejects_wrong_did() { + let v = verifier(3); + let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err(); + assert!(err.contains("subject"), "{err}"); + } + + #[test] + fn rejects_insufficient_level() { + let v = verifier(5); // proof is level 3 + let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err(); + assert!(err.contains("below required"), "{err}"); + } + + #[test] + fn rejects_tampered_signature() { + let v = verifier(3); + // Flip one base64url char in the signature so it is guaranteed different. + let (payload, sig) = PROOF.split_once('.').unwrap(); + let mut chars: Vec = sig.chars().collect(); + chars[0] = if chars[0] == 'A' { 'B' } else { 'A' }; + let tampered = format!("{}.{}", payload, chars.into_iter().collect::()); + assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err()); + } + + #[test] + fn rejects_missing_header() { + let v = verifier(3); + let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err(); + assert!(err.contains("missing"), "{err}"); + } + + #[test] + fn off_mode_allows_everything() { + let mut v = verifier(3); + v.mode = Mode::Off; + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + } + + #[test] + fn enforce_without_key_stays_inert() { + // iCaptcha unreachable at startup -> no key -> must not reject. + let v = Verifier { + mode: Mode::Enforce, + url: "https://icaptcha.gitlawb.com".to_string(), + required_level: 3, + key: None, + }; + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + } + + #[test] + fn enforce_with_key_rejects_missing_proof() { + let v = verifier(3); + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_err()); + } + + #[test] + fn shadow_allows_despite_bad_proof() { + let mut v = verifier(3); + v.mode = Mode::Shadow; + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + } + + #[test] + fn enforce_accepts_valid_proof_via_decide() { + let v = verifier(3); + assert!(decide(&v, &headers_with(PROOF), SUB, IAT).is_ok()); + } +} diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index c881634..b063d18 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -9,6 +9,7 @@ mod encrypted_pin; mod error; mod git; mod graphql; +mod icaptcha; mod ipfs_pin; mod metrics; mod operator; @@ -191,6 +192,9 @@ async fn main() -> Result<()> { let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600)); + // Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set). + icaptcha::init().await; + let state = AppState { config: Arc::new(config.clone()), db, From 28a3dad5ea9d0a3be2afe4698c6043f7eab6db0f Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sun, 28 Jun 2026 07:54:12 +0800 Subject: [PATCH 2/6] fix(node): address review on icaptcha gate (validate config, bound key fetch) Addresses CodeRabbit and @jatmn review feedback on #108: - parse_mode now returns Option and rejects unrecognized ICAPTCHA_MODE values; init() warns and falls back to off instead of silently disabling the gate on a typo like "enforced". [P2] - ICAPTCHA_REQUIRED_LEVEL: warn on a non-empty unparseable value instead of silently lowering the threshold to the default. [P2] - fetch_key uses a reqwest client with a 5s timeout so a hung /v1/pubkey can never block node startup; still fails inert on timeout/error. [P2] - .env.example: reorder ICAPTCHA_* keys (MODE, PUBKEY, REQUIRED_LEVEL, URL) to satisfy dotenv-linter. [P3] - add parse_mode unit test (rejects junk, accepts documented values). cargo test -p gitlawb-node icaptcha: 12 passed. clippy + fmt clean. Author: Kevin Codex --- .env.example | 4 +- crates/gitlawb-node/src/icaptcha.rs | 60 +++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 2c44710..5444865 100644 --- a/.env.example +++ b/.env.example @@ -102,10 +102,10 @@ GITLAWB_AUTO_SYNC=false # shadow - verify + log would-be rejections, but always allow # enforce - reject requests without a valid, strong-enough proof ICAPTCHA_MODE=off -# Base URL of the iCaptcha service. -ICAPTCHA_URL=https://icaptcha.gitlawb.com # Optional base64url Ed25519 public key override; if unset it is fetched from # ICAPTCHA_URL/v1/pubkey at startup. ICAPTCHA_PUBKEY= # Minimum proof difficulty level required to pass. ICAPTCHA_REQUIRED_LEVEL=3 +# Base URL of the iCaptcha service. +ICAPTCHA_URL=https://icaptcha.gitlawb.com diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs index 4d8e62a..7df77ca 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -19,7 +19,7 @@ //! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3) use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::http::HeaderMap; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -48,11 +48,31 @@ impl Mode { } } -fn parse_mode(s: &str) -> Mode { +/// Parse `ICAPTCHA_MODE`. Returns `None` for unrecognized values so the caller +/// can surface the typo instead of silently disabling the gate. +fn parse_mode(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { - "enforce" => Mode::Enforce, - "shadow" => Mode::Shadow, - _ => Mode::Off, + "" | "off" => Some(Mode::Off), + "shadow" => Some(Mode::Shadow), + "enforce" => Some(Mode::Enforce), + _ => None, + } +} + +/// Parse `ICAPTCHA_REQUIRED_LEVEL`. Defaults to 3; warns (rather than silently +/// lowering the threshold) when a non-empty value fails to parse. +fn parse_required_level() -> u32 { + const DEFAULT: u32 = 3; + match std::env::var("ICAPTCHA_REQUIRED_LEVEL") { + Ok(v) if !v.trim().is_empty() => v.trim().parse().unwrap_or_else(|_| { + tracing::warn!( + value = %v, + default = DEFAULT, + "invalid ICAPTCHA_REQUIRED_LEVEL; using default" + ); + DEFAULT + }), + _ => DEFAULT, } } @@ -97,20 +117,27 @@ fn decode_key(b64url: &str) -> Option { async fn fetch_key(url: &str) -> Option { let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/')); - let jwks: Jwks = reqwest::get(&endpoint).await.ok()?.json().await.ok()?; + // Bounded request: a hung /v1/pubkey must never block node startup. On + // timeout/error we return None and the gate stays inert (fail safe). + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .ok()?; + let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?; decode_key(&jwks.keys.first()?.x) } /// Initialize the gate from the environment. Call once at startup. Never panics; /// if the gate is active but no key can be loaded, it stays inert and warns. pub async fn init() { - let mode = parse_mode(&std::env::var("ICAPTCHA_MODE").unwrap_or_default()); + let raw_mode = std::env::var("ICAPTCHA_MODE").unwrap_or_default(); + let mode = parse_mode(&raw_mode).unwrap_or_else(|| { + tracing::warn!(value = %raw_mode, "invalid ICAPTCHA_MODE; disabling iCaptcha gate"); + Mode::Off + }); let url = std::env::var("ICAPTCHA_URL") .unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string()); - let required_level = std::env::var("ICAPTCHA_REQUIRED_LEVEL") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(3); + let required_level = parse_required_level(); let key = if mode == Mode::Off { None @@ -287,6 +314,17 @@ mod tests { assert!(err.contains("missing"), "{err}"); } + #[test] + fn parse_mode_accepts_documented_values_and_rejects_junk() { + assert_eq!(parse_mode(""), Some(Mode::Off)); + assert_eq!(parse_mode("off"), Some(Mode::Off)); + assert_eq!(parse_mode(" Shadow "), Some(Mode::Shadow)); + assert_eq!(parse_mode("ENFORCE"), Some(Mode::Enforce)); + // Typos must NOT silently disable the gate. + assert_eq!(parse_mode("enforced"), None); + assert_eq!(parse_mode("on"), None); + } + #[test] fn off_mode_allows_everything() { let mut v = verifier(3); From e976398ca5c7b2e3e25f13462f1f1404b863a5a3 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sun, 28 Jun 2026 08:53:50 +0800 Subject: [PATCH 3/6] 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 --- crates/gitlawb-node/src/api/register.rs | 2 +- crates/gitlawb-node/src/api/repos.rs | 2 +- crates/gitlawb-node/src/db/mod.rs | 41 +++++++++ crates/gitlawb-node/src/icaptcha.rs | 109 ++++++++++++++++++------ crates/gitlawb-node/src/main.rs | 10 ++- 5 files changed, 136 insertions(+), 28 deletions(-) diff --git a/crates/gitlawb-node/src/api/register.rs b/crates/gitlawb-node/src/api/register.rs index 036c7b5..6d198eb 100644 --- a/crates/gitlawb-node/src/api/register.rs +++ b/crates/gitlawb-node/src/api/register.rs @@ -40,7 +40,7 @@ pub async fn register( Json(req): Json, ) -> Result<(StatusCode, Json)> { // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). - crate::icaptcha::check(&headers, &auth.0)?; + crate::icaptcha::check(&state.db, &headers, &auth.0).await?; // Parse and validate the DID let agent_did: Did = req diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index c8cfbd1..e65d135 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -143,7 +143,7 @@ pub async fn create_repo( Json(req): Json, ) -> Result<(StatusCode, Json)> { // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). - crate::icaptcha::check(&headers, &auth.0)?; + crate::icaptcha::check(&state.db, &headers, &auth.0).await?; // Sanitize name: alphanumeric, hyphens, underscores only if !req diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 36e44ff..47ce153 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -781,6 +781,21 @@ const MIGRATIONS: &[Migration] = &[ "CREATE INDEX IF NOT EXISTS idx_repos_owner_key_name ON repos ((CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END), name)", ], }, + Migration { + version: 8, + name: "icaptcha_consumed_proofs", + stmts: &[ + // Single-use ledger for iCaptcha proof ids (jti). A proof may be + // spent once per gated action; replays are rejected until the row + // is swept after the proof's own expiry. `expires_at` is the + // proof's unix-seconds exp, used for cleanup. + r#"CREATE TABLE IF NOT EXISTS icaptcha_consumed_proofs ( + jti TEXT NOT NULL PRIMARY KEY, + expires_at BIGINT NOT NULL + )"#, + "CREATE INDEX IF NOT EXISTS idx_icaptcha_consumed_expires ON icaptcha_consumed_proofs(expires_at)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1097,6 +1112,32 @@ impl Db { Ok(()) } + /// Atomically consume an iCaptcha proof id (`jti`). Returns `Ok(true)` if it + /// was newly recorded (the proof may be used), `Ok(false)` if it was already + /// spent (a replay). `expires_at` is the proof's unix-seconds `exp`, kept so + /// the ledger row can be swept once the proof can no longer be valid. + pub async fn consume_proof_jti(&self, jti: &str, expires_at: i64) -> Result { + let result = sqlx::query( + "INSERT INTO icaptcha_consumed_proofs (jti, expires_at) + VALUES ($1, $2) + ON CONFLICT (jti) DO NOTHING", + ) + .bind(jti) + .bind(expires_at) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Delete consumed-proof rows whose proof has expired. Returns rows removed. + pub async fn sweep_expired_proofs(&self, now: i64) -> Result { + let result = sqlx::query("DELETE FROM icaptcha_consumed_proofs WHERE expires_at < $1") + .bind(now) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + pub async fn get_trust_score(&self, agent_did: &str) -> Result { let row = sqlx::query("SELECT trust_score FROM agents WHERE did = $1") .bind(agent_did) diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs index 7df77ca..737494e 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -85,11 +85,13 @@ struct Verifier { static VERIFIER: OnceLock = OnceLock::new(); -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] struct ProofClaims { sub: String, level: u32, exp: i64, + /// Unique proof id, consumed once so a proof cannot be replayed. + jti: String, } #[derive(Deserialize)] @@ -167,21 +169,53 @@ pub async fn init() { }); } +/// Outcome of the synchronous, IO-free decision step. +#[derive(Debug)] +enum Decision { + /// Allow the request (off, shadow, inert/no-key, or verified non-enforcing). + Allow, + /// Enforce mode and verification failed; reject with this reason. + Reject(String), + /// Enforce mode and the proof verified; the caller must consume this `jti` + /// (and reject replays) before allowing. + Consume { jti: String, exp: i64 }, +} + /// Gate an authenticated request. `did` is the authenticated agent DID the proof -/// must belong to. Returns `Ok(())` to allow, `Err(Unauthorized)` to reject. -/// Honors the configured mode (Off/Shadow never reject). -pub fn check(headers: &HeaderMap, did: &str) -> Result<(), AppError> { +/// must belong to. In enforce mode a valid proof is consumed once (its `jti` is +/// recorded in the DB) so it cannot be replayed across gated actions. Returns +/// `Ok(())` to allow, `Err(Unauthorized)` to reject. Off/Shadow never reject. +pub async fn check(db: &crate::db::Db, headers: &HeaderMap, did: &str) -> Result<(), AppError> { let v = match VERIFIER.get() { Some(v) => v, None => return Ok(()), // not initialized -> inert }; - decide(v, headers, did, now_secs()) + match decide(v, headers, did, now_secs()) { + Decision::Allow => Ok(()), + Decision::Reject(reason) => Err(reject_error(v, &reason)), + Decision::Consume { jti, exp } => { + // First use records the jti; a replay finds it already present. + if db.consume_proof_jti(&jti, exp).await? { + Ok(()) + } else { + Err(reject_error(v, "proof already used (replay)")) + } + } + } +} + +fn reject_error(v: &Verifier, reason: &str) -> AppError { + AppError::Unauthorized(format!( + "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", + v.url, v.required_level, PROOF_HEADER + )) } -/// Mode-aware decision, separated from the global state for testability. -fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), AppError> { +/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it +/// is fully unit-testable. The caller performs jti consumption for `Consume`. +fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision { if v.mode == Mode::Off { - return Ok(()); + return Decision::Allow; } // Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable @@ -189,27 +223,32 @@ fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), // already saw a startup warning. An iCaptcha hiccup must never break repo // creation or registration. if v.key.is_none() { - return Ok(()); + return Decision::Allow; } match verify(v, headers, did, now) { - Ok(()) => Ok(()), + Ok(claims) => match v.mode { + Mode::Enforce => Decision::Consume { + jti: claims.jti, + exp: claims.exp, + }, + // Shadow/Off: never reject, and do not consume (observational only). + _ => Decision::Allow, + }, Err(reason) => match v.mode { Mode::Shadow => { tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject"); - Ok(()) + Decision::Allow } - Mode::Enforce => Err(AppError::Unauthorized(format!( - "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", - v.url, v.required_level, PROOF_HEADER - ))), - Mode::Off => Ok(()), + Mode::Enforce => Decision::Reject(reason), + Mode::Off => Decision::Allow, }, } } -/// Core verification, separated for testability. `now` is unix seconds. -fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), String> { +/// Core verification, separated for testability. Returns the validated claims. +/// `now` is unix seconds. +fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result { let key = v.key.as_ref().ok_or("verifier has no public key")?; let proof = headers .get(PROOF_HEADER) @@ -241,7 +280,7 @@ fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), if !crate::api::did_matches(did, &claims.sub) { return Err("proof subject does not match authenticated DID".to_string()); } - Ok(()) + Ok(claims) } #[cfg(test)] @@ -329,7 +368,10 @@ mod tests { fn off_mode_allows_everything() { let mut v = verifier(3); v.mode = Mode::Off; - assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + assert!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); } #[test] @@ -341,25 +383,42 @@ mod tests { required_level: 3, key: None, }; - assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + assert!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); } #[test] fn enforce_with_key_rejects_missing_proof() { let v = verifier(3); - assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_err()); + assert!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Reject(_) + )); } #[test] fn shadow_allows_despite_bad_proof() { let mut v = verifier(3); v.mode = Mode::Shadow; - assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); + assert!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); } #[test] - fn enforce_accepts_valid_proof_via_decide() { + fn enforce_valid_proof_requires_consuming_its_jti() { + // A verified proof under enforce must yield Consume carrying the jti, so + // the caller can spend it once and reject replays. let v = verifier(3); - assert!(decide(&v, &headers_with(PROOF), SUB, IAT).is_ok()); + match decide(&v, &headers_with(PROOF), SUB, IAT) { + Decision::Consume { jti, exp } => { + assert_eq!(jti, "4b5228a5bed7122dee9f47ff"); + assert_eq!(exp, 1782573151); + } + other => panic!("expected Consume, got {other:?}"), + } } } diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index b063d18..6f48c3f 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -265,15 +265,23 @@ async fn main() -> Result<()> { }); } - // Periodic cleanup of expired rate limit entries + // Periodic cleanup of expired rate limit entries + consumed-proof ledger { let rl = state.rate_limiter.clone(); + let db = state.db.clone(); let mut shutdown_rx = state.subscribe_shutdown(); tokio::spawn(async move { loop { tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(300)) => { rl.cleanup().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if let Err(e) = db.sweep_expired_proofs(now).await { + tracing::warn!(err = %e, "failed to sweep expired iCaptcha proofs"); + } } _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { From 269d609d2863cb98c333dda43c81a36dc277ec4a Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sun, 28 Jun 2026 09:32:14 +0800 Subject: [PATCH 4/6] fix(node): consume iCaptcha proof only after request passes validation Addresses CodeRabbit (Major) on #108: check() spent the jti as soon as the proof verified, but create_repo/register ran it before name/DID/existence checks, so a rejected request (bad name, already-exists, wrong DID) permanently burned a valid proof without the action succeeding. Split verification from consumption: - icaptcha: replace check() with verify_request() -> ProofGuard (verifies and rejects invalid/missing proofs early; off/shadow/inert yield a no-op guard) plus ProofGuard::consume(db) which spends the jti right before the write and rejects replays. Guard is #[must_use]. - create_repo/register: verify_request() up front, then consume() immediately before the first write, after cheap validation passes. Replay protection unchanged (consume-before-write). 12 tests pass; clippy clean. Author: Kevin Codex --- crates/gitlawb-node/src/api/register.rs | 9 ++++- crates/gitlawb-node/src/api/repos.rs | 10 ++++- crates/gitlawb-node/src/icaptcha.rs | 51 +++++++++++++++++-------- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crates/gitlawb-node/src/api/register.rs b/crates/gitlawb-node/src/api/register.rs index 6d198eb..36ee801 100644 --- a/crates/gitlawb-node/src/api/register.rs +++ b/crates/gitlawb-node/src/api/register.rs @@ -39,8 +39,10 @@ pub async fn register( headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json)> { - // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). - crate::icaptcha::check(&state.db, &headers, &auth.0).await?; + // iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend + // the proof only once the request is admissible, just before the write, so a + // rejected request never burns a valid proof. + let proof = crate::icaptcha::verify_request(&headers, &auth.0)?; // Parse and validate the DID let agent_did: Did = req @@ -56,6 +58,9 @@ pub async fn register( )); } + // Request is admissible — spend the proof now, immediately before the write. + proof.consume(&state.db).await?; + // Store the agent in the local index state .db diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index e65d135..4873abc 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -142,8 +142,11 @@ pub async fn create_repo( headers: axum::http::HeaderMap, Json(req): Json, ) -> Result<(StatusCode, Json)> { - // iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set). - crate::icaptcha::check(&state.db, &headers, &auth.0).await?; + // iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify the proof up + // front so an invalid/missing proof is rejected early; the proof is only + // spent once the request is admissible, just before the first write — so a + // rejected request (bad name, already exists) never burns a valid proof. + let proof = crate::icaptcha::verify_request(&headers, &auth.0)?; // Sanitize name: alphanumeric, hyphens, underscores only if !req @@ -164,6 +167,9 @@ pub async fn create_repo( return Err(AppError::RepoExists(req.name)); } + // Request is admissible — spend the proof now, immediately before the write. + proof.consume(&state.db).await?; + let disk_path = state .repo_store .init(&owner_did, &req.name) diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs index 737494e..288b56d 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -181,26 +181,47 @@ enum Decision { Consume { jti: String, exp: i64 }, } -/// Gate an authenticated request. `did` is the authenticated agent DID the proof -/// must belong to. In enforce mode a valid proof is consumed once (its `jti` is -/// recorded in the DB) so it cannot be replayed across gated actions. Returns -/// `Ok(())` to allow, `Err(Unauthorized)` to reject. Off/Shadow never reject. -pub async fn check(db: &crate::db::Db, headers: &HeaderMap, did: &str) -> Result<(), AppError> { +/// A verified proof awaiting consumption. Verification (which rejects invalid or +/// missing proofs) is separated from consumption (which spends the single-use +/// `jti`) so a request rejected by later validation never burns a valid proof. +/// The caller must `consume()` this guard immediately before the gated write. +/// For off/shadow/inert there is nothing to consume. +#[must_use = "a verified iCaptcha proof must be consumed before the gated action"] +pub struct ProofGuard(Option); + +struct ConsumeJob { + jti: String, + exp: i64, +} + +impl ProofGuard { + /// Spend the proof's `jti` (single-use). A replay is rejected. No-op when + /// there is nothing to consume (off/shadow/inert). + pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> { + if let Some(job) = self.0 { + if !db.consume_proof_jti(&job.jti, job.exp).await? { + return Err(AppError::Unauthorized( + "iCaptcha proof already used (replay); solve a fresh challenge".to_string(), + )); + } + } + Ok(()) + } +} + +/// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid +/// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`] +/// the caller must `consume()` right before the gated write. Off/shadow/inert +/// yield a no-op guard that consumes nothing. +pub fn verify_request(headers: &HeaderMap, did: &str) -> Result { let v = match VERIFIER.get() { Some(v) => v, - None => return Ok(()), // not initialized -> inert + None => return Ok(ProofGuard(None)), // not initialized -> inert }; match decide(v, headers, did, now_secs()) { - Decision::Allow => Ok(()), + Decision::Allow => Ok(ProofGuard(None)), Decision::Reject(reason) => Err(reject_error(v, &reason)), - Decision::Consume { jti, exp } => { - // First use records the jti; a replay finds it already present. - if db.consume_proof_jti(&jti, exp).await? { - Ok(()) - } else { - Err(reject_error(v, "proof already used (replay)")) - } - } + Decision::Consume { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp }))), } } From 98121ca1420f8e9d1e6489b8540b8b6acecd9d00 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sun, 28 Jun 2026 10:13:32 +0800 Subject: [PATCH 5/6] fix(node): give iCaptcha rejections a dedicated error code Addresses CodeRabbit on #108: iCaptcha failure paths returned AppError::Unauthorized, which maps to the "not_an_agent" API code and conflates a valid-but-unproven agent with a failed signature. Add a dedicated AppError::IcaptchaProofRequired variant mapped to the "icaptcha_proof_required" code (HTTP 401), and use it for both the missing/invalid-proof and replay paths so clients can distinguish "solve a challenge" from "your signature is invalid". 12 tests pass; clippy + fmt clean. Author: Kevin Codex --- crates/gitlawb-node/src/error.rs | 10 ++++++++++ crates/gitlawb-node/src/icaptcha.rs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/gitlawb-node/src/error.rs b/crates/gitlawb-node/src/error.rs index 6c2775d..90ff2ff 100644 --- a/crates/gitlawb-node/src/error.rs +++ b/crates/gitlawb-node/src/error.rs @@ -23,6 +23,9 @@ pub enum AppError { #[allow(dead_code)] Forbidden(String), + #[error("icaptcha proof required: {0}")] + IcaptchaProofRequired(String), + #[error("invalid request: {0}")] BadRequest(String), @@ -52,6 +55,13 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()), AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()), + // Distinct from `not_an_agent`: the caller IS an authenticated agent + // but must present a valid, fresh iCaptcha proof to proceed. + AppError::IcaptchaProofRequired(msg) => ( + StatusCode::UNAUTHORIZED, + "icaptcha_proof_required", + msg.clone(), + ), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()), AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()), AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()), diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs index 288b56d..b0f941f 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -200,7 +200,7 @@ impl ProofGuard { pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> { if let Some(job) = self.0 { if !db.consume_proof_jti(&job.jti, job.exp).await? { - return Err(AppError::Unauthorized( + return Err(AppError::IcaptchaProofRequired( "iCaptcha proof already used (replay); solve a fresh challenge".to_string(), )); } @@ -226,7 +226,7 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result AppError { - AppError::Unauthorized(format!( + AppError::IcaptchaProofRequired(format!( "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", v.url, v.required_level, PROOF_HEADER )) From 66e7a346738221aed99781969f7cfd6cd88358b1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:03:15 +0000 Subject: [PATCH 6/6] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- crates/gitlawb-node/src/db/mod.rs | 13 ++++++++ crates/gitlawb-node/src/icaptcha.rs | 46 +++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 47ce153..5bcb800 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -1129,6 +1129,19 @@ impl Db { Ok(result.rows_affected() > 0) } + /// Read-only check if an iCaptcha proof id (`jti`) has already been consumed. + /// Returns `Ok(true)` if the jti is fresh (not yet consumed), `Ok(false)` if + /// it is a replay. Used by shadow mode to detect replays without mutating state. + pub async fn check_proof_jti(&self, jti: &str) -> Result { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM icaptcha_consumed_proofs WHERE jti = $1)", + ) + .bind(jti) + .fetch_one(&self.pool) + .await?; + Ok(!exists) + } + /// Delete consumed-proof rows whose proof has expired. Returns rows removed. pub async fn sweep_expired_proofs(&self, now: i64) -> Result { let result = sqlx::query("DELETE FROM icaptcha_consumed_proofs WHERE expires_at < $1") diff --git a/crates/gitlawb-node/src/icaptcha.rs b/crates/gitlawb-node/src/icaptcha.rs index b0f941f..6371f39 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -172,37 +172,53 @@ pub async fn init() { /// Outcome of the synchronous, IO-free decision step. #[derive(Debug)] enum Decision { - /// Allow the request (off, shadow, inert/no-key, or verified non-enforcing). + /// Allow the request (off, inert/no-key, or verified non-enforcing with no jti). Allow, /// Enforce mode and verification failed; reject with this reason. Reject(String), /// Enforce mode and the proof verified; the caller must consume this `jti` /// (and reject replays) before allowing. Consume { jti: String, exp: i64 }, + /// Shadow mode and the proof verified; the caller must check (read-only) this + /// `jti` for replays without mutating state. + ShadowCheck { jti: String, exp: i64 }, } /// A verified proof awaiting consumption. Verification (which rejects invalid or /// missing proofs) is separated from consumption (which spends the single-use /// `jti`) so a request rejected by later validation never burns a valid proof. /// The caller must `consume()` this guard immediately before the gated write. -/// For off/shadow/inert there is nothing to consume. +/// For off/inert there is nothing to consume. For shadow, a read-only replay +/// check runs without mutating state. #[must_use = "a verified iCaptcha proof must be consumed before the gated action"] pub struct ProofGuard(Option); struct ConsumeJob { jti: String, exp: i64, + enforce: bool, } impl ProofGuard { - /// Spend the proof's `jti` (single-use). A replay is rejected. No-op when - /// there is nothing to consume (off/shadow/inert). + /// Spend the proof's `jti` (single-use) in enforce mode, or check for replays + /// (read-only, no state mutation) in shadow mode. A replay is rejected in + /// enforce mode; in shadow mode it is logged but allowed. No-op when there is + /// nothing to consume (off/inert). pub async fn consume(self, db: &crate::db::Db) -> Result<(), AppError> { if let Some(job) = self.0 { - if !db.consume_proof_jti(&job.jti, job.exp).await? { - return Err(AppError::IcaptchaProofRequired( - "iCaptcha proof already used (replay); solve a fresh challenge".to_string(), - )); + if job.enforce { + // Enforce mode: consume the jti (mutate state) and reject replays. + if !db.consume_proof_jti(&job.jti, job.exp).await? { + return Err(AppError::IcaptchaProofRequired( + "iCaptcha proof already used (replay); solve a fresh challenge".to_string(), + )); + } + } else { + // Shadow mode: read-only replay check, log reuse but do not block. + let is_fresh = db.check_proof_jti(&job.jti).await?; + if !is_fresh { + tracing::warn!(jti = %job.jti, "iCaptcha (shadow) proof replay detected"); + } } } Ok(()) @@ -211,8 +227,8 @@ impl ProofGuard { /// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid /// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`] -/// the caller must `consume()` right before the gated write. Off/shadow/inert -/// yield a no-op guard that consumes nothing. +/// the caller must `consume()` right before the gated write. Off/inert yield a +/// no-op guard; shadow yields a read-only replay check; enforce consumes the jti. pub fn verify_request(headers: &HeaderMap, did: &str) -> Result { let v = match VERIFIER.get() { Some(v) => v, @@ -221,7 +237,8 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result Ok(ProofGuard(None)), Decision::Reject(reason) => Err(reject_error(v, &reason)), - Decision::Consume { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp }))), + Decision::Consume { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp, enforce: true }))), + Decision::ShadowCheck { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp, enforce: false }))), } } @@ -253,8 +270,11 @@ fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision { jti: claims.jti, exp: claims.exp, }, - // Shadow/Off: never reject, and do not consume (observational only). - _ => Decision::Allow, + Mode::Shadow => Decision::ShadowCheck { + jti: claims.jti, + exp: claims.exp, + }, + Mode::Off => Decision::Allow, }, Err(reason) => match v.mode { Mode::Shadow => {