Skip to content

Commit 2e258ee

Browse files
committed
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 <kevin@gitlawb.com>
1 parent 2153b0b commit 2e258ee

6 files changed

Lines changed: 341 additions & 0 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/gitlawb-node/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ path = "src/main.rs"
1313
[dependencies]
1414
gitlawb-core = { path = "../gitlawb-core" }
1515
ed25519-dalek = { workspace = true }
16+
base64 = { workspace = true }
1617
tokio = { workspace = true }
1718
serde = { workspace = true }
1819
serde_json = { workspace = true }

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ pub struct RegisterResponse {
3636
pub async fn register(
3737
State(state): State<AppState>,
3838
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
39+
headers: axum::http::HeaderMap,
3940
Json(req): Json<RegisterRequest>,
4041
) -> Result<(StatusCode, Json<RegisterResponse>)> {
42+
// iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
43+
crate::icaptcha::check(&headers, &auth.0)?;
44+
4145
// Parse and validate the DID
4246
let agent_did: Did = req
4347
.did

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ pub struct InfoRefsQuery {
6363
pub async fn create_repo(
6464
State(state): State<AppState>,
6565
Extension(auth): Extension<AuthenticatedDid>,
66+
headers: axum::http::HeaderMap,
6667
Json(req): Json<CreateRepoRequest>,
6768
) -> Result<(StatusCode, Json<RepoResponse>)> {
69+
// iCaptcha proof-of-intelligence gate (inert unless ICAPTCHA_MODE is set).
70+
crate::icaptcha::check(&headers, &auth.0)?;
71+
6872
// Sanitize name: alphanumeric, hyphens, underscores only
6973
if !req
7074
.name
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod encrypted_pin;
99
mod error;
1010
mod git;
1111
mod graphql;
12+
mod icaptcha;
1213
mod ipfs_pin;
1314
mod metrics;
1415
mod operator;
@@ -191,6 +192,9 @@ async fn main() -> Result<()> {
191192

192193
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
193194

195+
// Initialize the iCaptcha proof gate (inert unless ICAPTCHA_MODE is set).
196+
icaptcha::init().await;
197+
194198
let state = AppState {
195199
config: Arc::new(config.clone()),
196200
db,

0 commit comments

Comments
 (0)