|
| 1 | +//! iCaptcha proof-of-intelligence gate. |
| 2 | +//! |
| 3 | +//! Spam-prone endpoints (repo creation, agent registration) can require the |
| 4 | +//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by |
| 5 | +//! <https://icaptcha.gitlawb.com> after the caller solves an escalating |
| 6 | +//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha) |
| 7 | +//! using its published public key, and bind each proof to the authenticated |
| 8 | +//! agent DID so a proof cannot be shared between identities. |
| 9 | +//! |
| 10 | +//! Behaviour is controlled by `ICAPTCHA_MODE`: |
| 11 | +//! * `off` (default) — gate is inert, nothing is checked. |
| 12 | +//! * `shadow` — verify and log would-be rejections, but always allow. |
| 13 | +//! * `enforce` — reject requests without a valid, sufficiently-strong proof. |
| 14 | +//! |
| 15 | +//! Config (env): |
| 16 | +//! ICAPTCHA_MODE off | shadow | enforce (default off) |
| 17 | +//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com) |
| 18 | +//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey) |
| 19 | +//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3) |
| 20 | +
|
| 21 | +use std::sync::OnceLock; |
| 22 | +use std::time::{SystemTime, UNIX_EPOCH}; |
| 23 | + |
| 24 | +use axum::http::HeaderMap; |
| 25 | +use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 26 | +use base64::Engine; |
| 27 | +use ed25519_dalek::{Signature, VerifyingKey}; |
| 28 | +use serde::Deserialize; |
| 29 | + |
| 30 | +use crate::error::AppError; |
| 31 | + |
| 32 | +const PROOF_HEADER: &str = "x-icaptcha-proof"; |
| 33 | + |
| 34 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 35 | +pub enum Mode { |
| 36 | + Off, |
| 37 | + Shadow, |
| 38 | + Enforce, |
| 39 | +} |
| 40 | + |
| 41 | +impl Mode { |
| 42 | + fn as_str(self) -> &'static str { |
| 43 | + match self { |
| 44 | + Mode::Off => "off", |
| 45 | + Mode::Shadow => "shadow", |
| 46 | + Mode::Enforce => "enforce", |
| 47 | + } |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +fn parse_mode(s: &str) -> Mode { |
| 52 | + match s.trim().to_ascii_lowercase().as_str() { |
| 53 | + "enforce" => Mode::Enforce, |
| 54 | + "shadow" => Mode::Shadow, |
| 55 | + _ => Mode::Off, |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +struct Verifier { |
| 60 | + mode: Mode, |
| 61 | + url: String, |
| 62 | + required_level: u32, |
| 63 | + key: Option<VerifyingKey>, |
| 64 | +} |
| 65 | + |
| 66 | +static VERIFIER: OnceLock<Verifier> = OnceLock::new(); |
| 67 | + |
| 68 | +#[derive(Deserialize)] |
| 69 | +struct ProofClaims { |
| 70 | + sub: String, |
| 71 | + level: u32, |
| 72 | + exp: i64, |
| 73 | +} |
| 74 | + |
| 75 | +#[derive(Deserialize)] |
| 76 | +struct Jwk { |
| 77 | + x: String, |
| 78 | +} |
| 79 | + |
| 80 | +#[derive(Deserialize)] |
| 81 | +struct Jwks { |
| 82 | + keys: Vec<Jwk>, |
| 83 | +} |
| 84 | + |
| 85 | +fn now_secs() -> i64 { |
| 86 | + SystemTime::now() |
| 87 | + .duration_since(UNIX_EPOCH) |
| 88 | + .map(|d| d.as_secs() as i64) |
| 89 | + .unwrap_or(0) |
| 90 | +} |
| 91 | + |
| 92 | +fn decode_key(b64url: &str) -> Option<VerifyingKey> { |
| 93 | + let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?; |
| 94 | + let arr: [u8; 32] = bytes.try_into().ok()?; |
| 95 | + VerifyingKey::from_bytes(&arr).ok() |
| 96 | +} |
| 97 | + |
| 98 | +async fn fetch_key(url: &str) -> Option<VerifyingKey> { |
| 99 | + let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/')); |
| 100 | + let jwks: Jwks = reqwest::get(&endpoint).await.ok()?.json().await.ok()?; |
| 101 | + decode_key(&jwks.keys.first()?.x) |
| 102 | +} |
| 103 | + |
| 104 | +/// Initialize the gate from the environment. Call once at startup. Never panics; |
| 105 | +/// if the gate is active but no key can be loaded, it stays inert and warns. |
| 106 | +pub async fn init() { |
| 107 | + let mode = parse_mode(&std::env::var("ICAPTCHA_MODE").unwrap_or_default()); |
| 108 | + let url = std::env::var("ICAPTCHA_URL") |
| 109 | + .unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string()); |
| 110 | + let required_level = std::env::var("ICAPTCHA_REQUIRED_LEVEL") |
| 111 | + .ok() |
| 112 | + .and_then(|v| v.parse().ok()) |
| 113 | + .unwrap_or(3); |
| 114 | + |
| 115 | + let key = if mode == Mode::Off { |
| 116 | + None |
| 117 | + } else { |
| 118 | + match std::env::var("ICAPTCHA_PUBKEY") { |
| 119 | + Ok(b64) if !b64.is_empty() => decode_key(&b64), |
| 120 | + _ => fetch_key(&url).await, |
| 121 | + } |
| 122 | + }; |
| 123 | + |
| 124 | + if mode != Mode::Off { |
| 125 | + if key.is_some() { |
| 126 | + tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active"); |
| 127 | + } else { |
| 128 | + tracing::warn!( |
| 129 | + mode = mode.as_str(), |
| 130 | + "iCaptcha gate enabled but no public key could be loaded; staying inert" |
| 131 | + ); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + let _ = VERIFIER.set(Verifier { |
| 136 | + mode, |
| 137 | + url, |
| 138 | + required_level, |
| 139 | + key, |
| 140 | + }); |
| 141 | +} |
| 142 | + |
| 143 | +/// Gate an authenticated request. `did` is the authenticated agent DID the proof |
| 144 | +/// must belong to. Returns `Ok(())` to allow, `Err(Unauthorized)` to reject. |
| 145 | +/// Honors the configured mode (Off/Shadow never reject). |
| 146 | +pub fn check(headers: &HeaderMap, did: &str) -> Result<(), AppError> { |
| 147 | + let v = match VERIFIER.get() { |
| 148 | + Some(v) => v, |
| 149 | + None => return Ok(()), // not initialized -> inert |
| 150 | + }; |
| 151 | + decide(v, headers, did, now_secs()) |
| 152 | +} |
| 153 | + |
| 154 | +/// Mode-aware decision, separated from the global state for testability. |
| 155 | +fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), AppError> { |
| 156 | + if v.mode == Mode::Off { |
| 157 | + return Ok(()); |
| 158 | + } |
| 159 | + |
| 160 | + // Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable |
| 161 | + // at startup), stay inert rather than rejecting every request. The operator |
| 162 | + // already saw a startup warning. An iCaptcha hiccup must never break repo |
| 163 | + // creation or registration. |
| 164 | + if v.key.is_none() { |
| 165 | + return Ok(()); |
| 166 | + } |
| 167 | + |
| 168 | + match verify(v, headers, did, now) { |
| 169 | + Ok(()) => Ok(()), |
| 170 | + Err(reason) => match v.mode { |
| 171 | + Mode::Shadow => { |
| 172 | + tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject"); |
| 173 | + Ok(()) |
| 174 | + } |
| 175 | + Mode::Enforce => Err(AppError::Unauthorized(format!( |
| 176 | + "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", |
| 177 | + v.url, v.required_level, PROOF_HEADER |
| 178 | + ))), |
| 179 | + Mode::Off => Ok(()), |
| 180 | + }, |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +/// Core verification, separated for testability. `now` is unix seconds. |
| 185 | +fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<(), String> { |
| 186 | + let key = v.key.as_ref().ok_or("verifier has no public key")?; |
| 187 | + let proof = headers |
| 188 | + .get(PROOF_HEADER) |
| 189 | + .and_then(|h| h.to_str().ok()) |
| 190 | + .ok_or("missing proof header")?; |
| 191 | + |
| 192 | + let (payload, sig_b64) = proof.split_once('.').ok_or("malformed proof")?; |
| 193 | + let sig_bytes = URL_SAFE_NO_PAD |
| 194 | + .decode(sig_b64) |
| 195 | + .map_err(|_| "bad signature encoding")?; |
| 196 | + let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?; |
| 197 | + key.verify_strict(payload.as_bytes(), &sig) |
| 198 | + .map_err(|_| "signature verification failed")?; |
| 199 | + |
| 200 | + let claims_bytes = URL_SAFE_NO_PAD |
| 201 | + .decode(payload) |
| 202 | + .map_err(|_| "bad payload encoding")?; |
| 203 | + let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?; |
| 204 | + |
| 205 | + if claims.exp < now { |
| 206 | + return Err("proof expired".to_string()); |
| 207 | + } |
| 208 | + if claims.level < v.required_level { |
| 209 | + return Err(format!( |
| 210 | + "level {} below required {}", |
| 211 | + claims.level, v.required_level |
| 212 | + )); |
| 213 | + } |
| 214 | + if !crate::api::did_matches(did, &claims.sub) { |
| 215 | + return Err("proof subject does not match authenticated DID".to_string()); |
| 216 | + } |
| 217 | + Ok(()) |
| 218 | +} |
| 219 | + |
| 220 | +#[cfg(test)] |
| 221 | +mod tests { |
| 222 | + use super::*; |
| 223 | + |
| 224 | + // Real values captured from https://icaptcha.gitlawb.com (a live proof). |
| 225 | + const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8"; |
| 226 | + const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA"; |
| 227 | + const SUB: &str = "did:key:ztest"; |
| 228 | + const IAT: i64 = 1782572851; // within the proof's validity window |
| 229 | + |
| 230 | + fn verifier(level: u32) -> Verifier { |
| 231 | + Verifier { |
| 232 | + mode: Mode::Enforce, |
| 233 | + url: "https://icaptcha.gitlawb.com".to_string(), |
| 234 | + required_level: level, |
| 235 | + key: decode_key(PUBKEY_X), |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + fn headers_with(proof: &str) -> HeaderMap { |
| 240 | + let mut h = HeaderMap::new(); |
| 241 | + h.insert(PROOF_HEADER, proof.parse().unwrap()); |
| 242 | + h |
| 243 | + } |
| 244 | + |
| 245 | + #[test] |
| 246 | + fn accepts_a_real_proof() { |
| 247 | + let v = verifier(3); |
| 248 | + assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok()); |
| 249 | + } |
| 250 | + |
| 251 | + #[test] |
| 252 | + fn rejects_expired_proof() { |
| 253 | + let v = verifier(3); |
| 254 | + let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err(); |
| 255 | + assert!(err.contains("expired"), "{err}"); |
| 256 | + } |
| 257 | + |
| 258 | + #[test] |
| 259 | + fn rejects_wrong_did() { |
| 260 | + let v = verifier(3); |
| 261 | + let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err(); |
| 262 | + assert!(err.contains("subject"), "{err}"); |
| 263 | + } |
| 264 | + |
| 265 | + #[test] |
| 266 | + fn rejects_insufficient_level() { |
| 267 | + let v = verifier(5); // proof is level 3 |
| 268 | + let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err(); |
| 269 | + assert!(err.contains("below required"), "{err}"); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn rejects_tampered_signature() { |
| 274 | + let v = verifier(3); |
| 275 | + // Flip one base64url char in the signature so it is guaranteed different. |
| 276 | + let (payload, sig) = PROOF.split_once('.').unwrap(); |
| 277 | + let mut chars: Vec<char> = sig.chars().collect(); |
| 278 | + chars[0] = if chars[0] == 'A' { 'B' } else { 'A' }; |
| 279 | + let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>()); |
| 280 | + assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err()); |
| 281 | + } |
| 282 | + |
| 283 | + #[test] |
| 284 | + fn rejects_missing_header() { |
| 285 | + let v = verifier(3); |
| 286 | + let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err(); |
| 287 | + assert!(err.contains("missing"), "{err}"); |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn off_mode_allows_everything() { |
| 292 | + let mut v = verifier(3); |
| 293 | + v.mode = Mode::Off; |
| 294 | + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); |
| 295 | + } |
| 296 | + |
| 297 | + #[test] |
| 298 | + fn enforce_without_key_stays_inert() { |
| 299 | + // iCaptcha unreachable at startup -> no key -> must not reject. |
| 300 | + let v = Verifier { |
| 301 | + mode: Mode::Enforce, |
| 302 | + url: "https://icaptcha.gitlawb.com".to_string(), |
| 303 | + required_level: 3, |
| 304 | + key: None, |
| 305 | + }; |
| 306 | + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); |
| 307 | + } |
| 308 | + |
| 309 | + #[test] |
| 310 | + fn enforce_with_key_rejects_missing_proof() { |
| 311 | + let v = verifier(3); |
| 312 | + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_err()); |
| 313 | + } |
| 314 | + |
| 315 | + #[test] |
| 316 | + fn shadow_allows_despite_bad_proof() { |
| 317 | + let mut v = verifier(3); |
| 318 | + v.mode = Mode::Shadow; |
| 319 | + assert!(decide(&v, &HeaderMap::new(), SUB, IAT).is_ok()); |
| 320 | + } |
| 321 | + |
| 322 | + #[test] |
| 323 | + fn enforce_accepts_valid_proof_via_decide() { |
| 324 | + let v = verifier(3); |
| 325 | + assert!(decide(&v, &headers_with(PROOF), SUB, IAT).is_ok()); |
| 326 | + } |
| 327 | +} |
0 commit comments