diff --git a/.env.example b/.env.example index cd5b5aa..5444865 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 +# 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/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..36ee801 100644 --- a/crates/gitlawb-node/src/api/register.rs +++ b/crates/gitlawb-node/src/api/register.rs @@ -36,8 +36,14 @@ 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 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 .did @@ -52,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 2065438..4873abc 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -139,8 +139,15 @@ 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 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 .name @@ -160,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/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 36e44ff..5bcb800 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,45 @@ 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) + } + + /// 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") + .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/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 new file mode 100644 index 0000000..6371f39 --- /dev/null +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -0,0 +1,465 @@ +//! 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::{Duration, 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", + } + } +} + +/// 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() { + "" | "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, + } +} + +struct Verifier { + mode: Mode, + url: String, + required_level: u32, + key: Option, +} + +static VERIFIER: OnceLock = OnceLock::new(); + +#[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)] +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('/')); + // 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 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 = parse_required_level(); + + 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, + }); +} + +/// Outcome of the synchronous, IO-free decision step. +#[derive(Debug)] +enum Decision { + /// 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/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) 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 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(()) + } +} + +/// 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/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, + None => return Ok(ProofGuard(None)), // not initialized -> inert + }; + match decide(v, headers, did, now_secs()) { + Decision::Allow => Ok(ProofGuard(None)), + Decision::Reject(reason) => Err(reject_error(v, &reason)), + Decision::Consume { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp, enforce: true }))), + Decision::ShadowCheck { jti, exp } => Ok(ProofGuard(Some(ConsumeJob { jti, exp, enforce: false }))), + } +} + +fn reject_error(v: &Verifier, reason: &str) -> AppError { + 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 + )) +} + +/// 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 Decision::Allow; + } + + // 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 Decision::Allow; + } + + match verify(v, headers, did, now) { + Ok(claims) => match v.mode { + Mode::Enforce => Decision::Consume { + jti: claims.jti, + exp: claims.exp, + }, + Mode::Shadow => Decision::ShadowCheck { + jti: claims.jti, + exp: claims.exp, + }, + Mode::Off => Decision::Allow, + }, + Err(reason) => match v.mode { + Mode::Shadow => { + tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject"); + Decision::Allow + } + Mode::Enforce => Decision::Reject(reason), + Mode::Off => Decision::Allow, + }, + } +} + +/// 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) + .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(claims) +} + +#[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 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); + v.mode = Mode::Off; + assert!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); + } + + #[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!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); + } + + #[test] + fn enforce_with_key_rejects_missing_proof() { + let v = verifier(3); + 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!(matches!( + decide(&v, &HeaderMap::new(), SUB, IAT), + Decision::Allow + )); + } + + #[test] + 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); + 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 c881634..6f48c3f 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, @@ -261,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() {