diff --git a/Cargo.lock b/Cargo.lock index d42c370..7ade2e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,10 +3300,11 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "gitlawb-core", + "icaptcha-client", "reqwest", "tracing", "tracing-subscriber", @@ -3311,7 +3312,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.3.9" +version = "0.4.0" dependencies = [ "base64", "ed25519-dalek", @@ -3328,7 +3329,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.3.9" +version = "0.4.0" dependencies = [ "anyhow", "base64", @@ -3355,7 +3356,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", @@ -3411,7 +3412,7 @@ dependencies = [ [[package]] name = "gl" -version = "0.3.9" +version = "0.4.0" dependencies = [ "alloy", "anyhow", @@ -3419,6 +3420,7 @@ dependencies = [ "clap", "dirs", "gitlawb-core", + "icaptcha-client", "mockito", "reqwest", "serde", @@ -3813,6 +3815,19 @@ dependencies = [ "cc", ] +[[package]] +name = "icaptcha-client" +version = "0.4.0" +dependencies = [ + "anyhow", + "gitlawb-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "icu_collections" version = "2.1.1" diff --git a/Cargo.toml b/Cargo.toml index 3d6ef78..b2fd6c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/gl", "crates/git-remote-gitlawb", "crates/gitlawb-attest", + "crates/icaptcha-client", ] [workspace.package] diff --git a/README.md b/README.md index dbd9430..6adcb4c 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,42 @@ git clone gitlawb://did:key:z6Mk.../my-repo For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development. +### Full lifecycle against an iCaptcha-enforcing node + +Public nodes (e.g. `node.gitlawb.com`) require two things on writes: + +1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl` + and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI + fails with `401 not_an_agent`; `gl` will tell you to upgrade and register. +2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**). + `gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the + `x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it + locally (arithmetic / algebra / sequence), and **retries the same signed request** + with the `x-icaptcha-proof` header — no manual steps, no env vars. + +```bash +gl identity new # create did:key identity +gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha +gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha +git push origin2 main # origin2 = gitlawb:///memlawb (signed) +git clone gitlawb:///memlawb # public read, no proof needed +gl doctor # preflight: identity, node, version, iCaptcha +``` + +Notes: + +- **`requesterId` is always your DID.** The proof's `sub` claim must equal the + authenticated signer; `gl`/helper set this automatically and the node enforces + `sub == authenticated DID` (so a proof minted for another identity is rejected). +- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between + solving and use, the client transparently solves a fresh one and retries. +- **What needs what:** create / fork / register are signed **and** iCaptcha-gated; + `git push` is **signed-only** (owner signature is the gate — no per-push challenge); + reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a + clear `404`, never a placeholder. +- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_API_KEY` and the client + sends it as a bearer token to the iCaptcha service. + --- ## Architecture diff --git a/crates/git-remote-gitlawb/Cargo.toml b/crates/git-remote-gitlawb/Cargo.toml index c772a52..c81aec7 100644 --- a/crates/git-remote-gitlawb/Cargo.toml +++ b/crates/git-remote-gitlawb/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] gitlawb-core = { path = "../gitlawb-core" } +icaptcha-client = { path = "../icaptcha-client" } anyhow = { workspace = true } reqwest = { workspace = true } tracing = { workspace = true } diff --git a/crates/git-remote-gitlawb/src/main.rs b/crates/git-remote-gitlawb/src/main.rs index 63c3b79..5f994ea 100644 --- a/crates/git-remote-gitlawb/src/main.rs +++ b/crates/git-remote-gitlawb/src/main.rs @@ -19,8 +19,34 @@ use anyhow::{bail, Context, Result}; use gitlawb_core::http_sig::sign_request; use gitlawb_core::identity::Keypair; +use icaptcha_client::IcaptchaCfg; use std::io::{self, BufRead, Read, Write}; +/// Max iCaptcha solve+retry attempts on a 403 push response. +const ICAPTCHA_MAX_RETRIES: u32 = 2; + +/// If a 403 response advertises an iCaptcha challenge (`x-icaptcha-*` headers), +/// build the solve config binding the proof `sub` to our DID. `None` for a +/// non-iCaptcha 403 or when there's no keypair to bind to. +fn icaptcha_cfg_from_headers( + headers: &reqwest::header::HeaderMap, + keypair: Option<&Keypair>, +) -> Option { + let url = headers.get("x-icaptcha-url").and_then(|v| v.to_str().ok()); + let level = headers + .get("x-icaptcha-level") + .and_then(|v| v.to_str().ok()); + if url.is_none() && level.is_none() { + return None; + } + let kp = keypair?; + Some(IcaptchaCfg::new( + kp.did().to_string(), + url.map(str::to_string), + level.and_then(|l| l.parse().ok()), + )) +} + fn main() -> Result<()> { let args: Vec = std::env::args().collect(); @@ -269,37 +295,56 @@ fn handle_connect( // Extract the URL path for signing (e.g., "/z6Mk.../my-repo/git-receive-pack") let path_for_sig = url_path(&post_url); - let mut req = client - .post(&post_url) - .header("Content-Type", format!("application/x-{}-request", service)) - .header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0"); - - // Add RFC 9421 HTTP Signature auth on push operations - if service == "git-receive-pack" { - if let Some(kp) = keypair { - let signed = sign_request(kp, "POST", &path_for_sig, &request_body); - req = req - .header("Content-Digest", signed.content_digest) - .header("Signature-Input", signed.signature_input) - .header("Signature", signed.signature); - tracing::debug!("attached RFC 9421 HTTP Signature (DID: {})", kp.did()); - } else { - tracing::warn!( - "no identity keypair found — push will be unsigned (v0.1 local alpha only)" - ); + // Send the pack, signing push (git-receive-pack) with RFC 9421. If the node + // ever answers a push with a 403 iCaptcha challenge (push is signed-only + // today, so this is a safety net), solve it and retry with the proof header — + // the same flow `gl` uses. The happy path moves the body (no clone); a clone + // happens only on the rare retry. + let mut proof: Option = None; + let mut attempts = 0u32; + let pack_resp = loop { + let mut req = client + .post(&post_url) + .header("Content-Type", format!("application/x-{}-request", service)) + .header("User-Agent", "git/2.0 git-remote-gitlawb/0.4.0"); + + if service == "git-receive-pack" { + if let Some(kp) = keypair { + let signed = sign_request(kp, "POST", &path_for_sig, &request_body); + req = req + .header("Content-Digest", signed.content_digest) + .header("Signature-Input", signed.signature_input) + .header("Signature", signed.signature); + tracing::debug!("attached RFC 9421 HTTP Signature (DID: {})", kp.did()); + } else { + tracing::warn!( + "no identity keypair found — push will be unsigned (v0.1 local alpha only)" + ); + } + } + if let Some(p) = &proof { + req = req.header(icaptcha_client::PROOF_HEADER, p); } - } - // Attach the body after signing so the pack bytes are moved, not cloned — - // packs can be large and the clone doubled peak memory on push. - let pack_resp = req - .body(request_body) - .send() - .with_context(|| format!("POST {post_url}"))?; + // Clone the pack so the body survives a possible iCaptcha retry. (Push + // is signed-only today, so the retry path is essentially never taken.) + let resp = req + .body(request_body.clone()) + .send() + .with_context(|| format!("POST {post_url}"))?; - if !pack_resp.status().is_success() { - bail!("POST /{} returned {}", service, pack_resp.status()); - } + if resp.status().is_success() { + break resp; + } + if resp.status().as_u16() == 403 && attempts < ICAPTCHA_MAX_RETRIES { + if let Some(cfg) = icaptcha_cfg_from_headers(resp.headers(), keypair) { + attempts += 1; + proof = Some(icaptcha_client::obtain_proof(&cfg, None)?); + continue; + } + } + bail!("POST /{} returned {}", service, resp.status()); + }; let pack_bytes = pack_resp.bytes().context("reading pack response")?; tracing::debug!("pack response: {} bytes from node", pack_bytes.len()); diff --git a/crates/gitlawb-node/src/error.rs b/crates/gitlawb-node/src/error.rs index b22424d..fdb45a3 100644 --- a/crates/gitlawb-node/src/error.rs +++ b/crates/gitlawb-node/src/error.rs @@ -23,8 +23,14 @@ pub enum AppError { #[allow(dead_code)] Forbidden(String), - #[error("icaptcha proof required: {0}")] - IcaptchaProofRequired(String), + #[error("icaptcha proof required: {message}")] + IcaptchaProofRequired { + message: String, + /// iCaptcha service base URL the client should solve against. + url: String, + /// Minimum proof level this node requires. + level: u32, + }, #[error("invalid request: {0}")] BadRequest(String), @@ -41,6 +47,34 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { + // iCaptcha challenges carry structured discovery so clients don't have to + // scrape the message: the service URL and required level are returned as + // both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers + // (mirroring the header-bearing `human_detected` response in auth/mod.rs). + if let AppError::IcaptchaProofRequired { + message, + url, + level, + } = &self + { + use axum::http::HeaderValue; + let body = Json(json!({ + "error": "icaptcha_proof_required", + "message": message, + "icaptcha_url": url, + "required_level": level, + })); + let mut resp = (StatusCode::FORBIDDEN, body).into_response(); + let headers = resp.headers_mut(); + if let Ok(v) = HeaderValue::from_str(url) { + headers.insert("x-icaptcha-url", v); + } + if let Ok(v) = HeaderValue::from_str(&level.to_string()) { + headers.insert("x-icaptcha-level", v); + } + return resp; + } + let (status, code, message) = match &self { AppError::RepoNotFound(r) => ( StatusCode::NOT_FOUND, @@ -55,15 +89,8 @@ 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()), - // 403, not 401: the caller IS an authenticated agent (credentials are - // valid) but is forbidden from this action without a valid, fresh - // iCaptcha proof. The distinct `icaptcha_proof_required` code — which - // clients branch on — keeps it separable from a plain `forbidden`. - AppError::IcaptchaProofRequired(msg) => ( - StatusCode::FORBIDDEN, - "icaptcha_proof_required", - msg.clone(), - ), + // IcaptchaProofRequired is handled above (it carries extra headers/fields). + AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"), 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 c8e36cd..4266866 100644 --- a/crates/gitlawb-node/src/icaptcha.rs +++ b/crates/gitlawb-node/src/icaptcha.rs @@ -237,9 +237,13 @@ impl ProofGuard { match self.0 { Some(p) => { if !db.consume_proof_jti(&p.jti, p.exp).await? { - return Err(AppError::IcaptchaProofRequired( - "iCaptcha proof already used (replay); solve a fresh challenge".to_string(), - )); + let (url, level) = url_and_level(); + return Err(AppError::IcaptchaProofRequired { + message: "iCaptcha proof already used (replay); solve a fresh challenge" + .to_string(), + url, + level, + }); } Ok(Some(p)) } @@ -265,10 +269,23 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result 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 - )) + AppError::IcaptchaProofRequired { + message: format!( + "iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.", + v.url, v.required_level, PROOF_HEADER + ), + url: v.url.clone(), + level: v.required_level, + } +} + +/// iCaptcha service url + required level for error responses, read from the +/// initialized verifier (falls back to defaults if somehow uninitialized). +fn url_and_level() -> (String, u32) { + VERIFIER + .get() + .map(|v| (v.url.clone(), v.required_level)) + .unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3)) } /// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it diff --git a/crates/gl/Cargo.toml b/crates/gl/Cargo.toml index ea27540..c605eb4 100644 --- a/crates/gl/Cargo.toml +++ b/crates/gl/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] gitlawb-core = { path = "../gitlawb-core" } +icaptcha-client = { path = "../icaptcha-client" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/gl/src/doctor.rs b/crates/gl/src/doctor.rs index 4c019bb..a7d2058 100644 --- a/crates/gl/src/doctor.rs +++ b/crates/gl/src/doctor.rs @@ -174,6 +174,19 @@ pub async fn run(args: DoctorArgs) -> Result<()> { "node", format!("{} — v{version} ({short_did}…)", args.node), )); + // Capability drift: a node newer than this CLI may require features + // (RFC 9421 signing, iCaptcha) the CLI doesn't speak. + let gl_ver = env!("CARGO_PKG_VERSION"); + if version != "?" && is_newer(version, gl_ver) { + checks.push(Check::warn( + "gl version", + format!( + "node is v{version} but gl is v{gl_ver} — your CLI may be missing \ + features (signing / iCaptcha) this node requires" + ), + "upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh", + )); + } } Ok(resp) => { checks.push(Check::fail( @@ -191,6 +204,31 @@ pub async fn run(args: DoctorArgs) -> Result<()> { } } + // ── 4b. iCaptcha capability ─────────────────────────────────────────── + // Gated writes (repo create / register / fork) auto-solve a challenge at the + // iCaptcha service; check it's reachable so the failure mode is obvious. + let icaptcha_url = std::env::var("GITLAWB_ICAPTCHA_URL") + .unwrap_or_else(|_| icaptcha_client::DEFAULT_URL.to_string()); + match NodeClient::new(&icaptcha_url, None).get("/v1/pubkey").await { + Ok(resp) if resp.status().is_success() => { + checks.push(Check::pass("iCaptcha", format!("{icaptcha_url} reachable"))); + } + Ok(resp) => { + checks.push(Check::warn( + "iCaptcha", + format!("{icaptcha_url} returned HTTP {}", resp.status()), + "gated writes (repo create / register) may fail until iCaptcha is reachable", + )); + } + Err(e) => { + checks.push(Check::warn( + "iCaptcha", + format!("{icaptcha_url} unreachable: {e}"), + "set GITLAWB_ICAPTCHA_URL or check connectivity — repo create / register solve a challenge there", + )); + } + } + // ── 5. git-remote-gitlawb helper ────────────────────────────────────── // Use PATH lookup only — invoking the binary directly triggers git internals // that error with "fatal: not a git repository" outside of a git repo. diff --git a/crates/gl/src/http.rs b/crates/gl/src/http.rs index 32e90a1..10372b2 100644 --- a/crates/gl/src/http.rs +++ b/crates/gl/src/http.rs @@ -1,8 +1,19 @@ //! Signed HTTP client for gitlawb API calls (async). +//! +//! Writes are signed with RFC 9421 HTTP Signatures. When the node gates a write +//! behind iCaptcha (HTTP 403 `icaptcha_proof_required`, advertised via the +//! `x-icaptcha-url` / `x-icaptcha-level` headers), the client transparently +//! solves the challenge and retries the same signed request with the +//! `x-icaptcha-proof` header — see `crates/icaptcha-client`. use anyhow::{Context, Result}; use gitlawb_core::http_sig::sign_request; use gitlawb_core::identity::Keypair; +use icaptcha_client::IcaptchaCfg; + +/// Max times we'll fetch a fresh proof and retry a 403-iCaptcha response +/// (absorbs proof expiry / first-seen replay). +const MAX_ICAPTCHA_RETRIES: usize = 2; pub struct NodeClient { inner: reqwest::Client, @@ -52,63 +63,116 @@ impl NodeClient { req.send().await.with_context(|| format!("GET {url}")) } - /// POST with JSON body + RFC 9421 HTTP Signature auth. + /// POST with JSON body + RFC 9421 signing + transparent iCaptcha solve/retry. pub async fn post(&self, path: &str, body: &[u8]) -> Result { - let url = format!("{}{}", self.node_url, path); - let mut req = self - .inner - .post(&url) - .header("Content-Type", "application/json") - .body(body.to_vec()); + self.send_signed("POST", path, body).await + } - if let Some(kp) = &self.keypair { - let signed = sign_request(kp, "POST", path, body); - req = req - .header("Content-Digest", signed.content_digest) - .header("Signature-Input", signed.signature_input) - .header("Signature", signed.signature); - } + /// PUT with RFC 9421 signing + transparent iCaptcha solve/retry. + pub async fn put(&self, path: &str, body: &[u8]) -> Result { + self.send_signed("PUT", path, body).await + } - req.send().await.with_context(|| format!("POST {url}")) + /// DELETE with RFC 9421 signing + transparent iCaptcha solve/retry. + pub async fn delete(&self, path: &str, body: &[u8]) -> Result { + self.send_signed("DELETE", path, body).await } - /// PUT with RFC 9421 HTTP Signature auth (idempotent write). - pub async fn put(&self, path: &str, body: &[u8]) -> Result { - let url = format!("{}{}", self.node_url, path); - let mut req = self - .inner - .put(&url) - .header("Content-Type", "application/json") - .body(body.to_vec()); + /// Sign + send a write. On a 403 iCaptcha challenge (detected via the + /// `x-icaptcha-*` headers) solve it and retry the same signed request with + /// the proof header, up to [`MAX_ICAPTCHA_RETRIES`]. Emits an actionable + /// hint on a 401 "not an agent" (the old-CLI / unregistered failure mode). + async fn send_signed( + &self, + method: &str, + path: &str, + body: &[u8], + ) -> Result { + let mut proof: Option = None; + let mut attempts = 0; + loop { + let resp = self.send_once(method, path, body, proof.as_deref()).await?; + let status = resp.status(); - if let Some(kp) = &self.keypair { - let signed = sign_request(kp, "PUT", path, body); - req = req - .header("Content-Digest", signed.content_digest) - .header("Signature-Input", signed.signature_input) - .header("Signature", signed.signature); - } + if status == reqwest::StatusCode::UNAUTHORIZED + && resp + .headers() + .get("x-gitlawb-error") + .and_then(|v| v.to_str().ok()) + == Some("human_detected") + { + eprintln!( + "note: this node requires signed requests (RFC 9421). If writes keep \ + failing, your `gl` may be too old — upgrade it — or you're not registered: \ + run `gl register`." + ); + } - req.send().await.with_context(|| format!("PUT {url}")) + if status == reqwest::StatusCode::FORBIDDEN && attempts < MAX_ICAPTCHA_RETRIES { + if let Some(cfg) = self.icaptcha_cfg(resp.headers())? { + attempts += 1; + proof = Some(obtain_proof(cfg).await?); + continue; + } + } + return Ok(resp); + } } - /// DELETE with RFC 9421 HTTP Signature auth. - pub async fn delete(&self, path: &str, body: &[u8]) -> Result { + /// Build, sign, and send one request, optionally attaching a proof header. + async fn send_once( + &self, + method: &str, + path: &str, + body: &[u8], + proof: Option<&str>, + ) -> Result { let url = format!("{}{}", self.node_url, path); let mut req = self .inner - .delete(&url) + .request(method.parse().expect("valid HTTP method"), &url) .header("Content-Type", "application/json") .body(body.to_vec()); if let Some(kp) = &self.keypair { - let signed = sign_request(kp, "DELETE", path, body); + let signed = sign_request(kp, method, path, body); req = req .header("Content-Digest", signed.content_digest) .header("Signature-Input", signed.signature_input) .header("Signature", signed.signature); } + if let Some(p) = proof { + req = req.header(icaptcha_client::PROOF_HEADER, p); + } - req.send().await.with_context(|| format!("DELETE {url}")) + req.send().await.with_context(|| format!("{method} {url}")) } + + /// If `headers` describe an iCaptcha 403, build the solve config (binding the + /// proof's `sub` to our DID). Returns `None` for a non-iCaptcha 403. + fn icaptcha_cfg(&self, headers: &reqwest::header::HeaderMap) -> Result> { + let url = headers.get("x-icaptcha-url").and_then(|v| v.to_str().ok()); + let level = headers + .get("x-icaptcha-level") + .and_then(|v| v.to_str().ok()); + if url.is_none() && level.is_none() { + return Ok(None); // not an iCaptcha challenge + } + let kp = self + .keypair + .as_ref() + .context("iCaptcha challenge requires an identity keypair (run `gl identity new`)")?; + Ok(Some(IcaptchaCfg::new( + kp.did().to_string(), + url.map(str::to_string), + level.and_then(|l| l.parse().ok()), + ))) + } +} + +/// Run the (blocking) iCaptcha solve loop off the async runtime. +async fn obtain_proof(cfg: IcaptchaCfg) -> Result { + tokio::task::spawn_blocking(move || icaptcha_client::obtain_proof(&cfg, None)) + .await + .context("iCaptcha solver task panicked")? } diff --git a/crates/gl/src/repo.rs b/crates/gl/src/repo.rs index 455a55f..5bfa7fd 100644 --- a/crates/gl/src/repo.rs +++ b/crates/gl/src/repo.rs @@ -208,20 +208,16 @@ pub async fn run(args: RepoArgs) -> Result<()> { } /// Derive the short DID key segment from a keypair, or fall back to the node's DID. -async fn resolve_owner_did(node: &str, dir: Option<&std::path::Path>) -> Result { - if let Ok(kp) = load_keypair_from_dir(dir) { - let did = kp.did().to_string(); - return Ok(did.split(':').next_back().unwrap_or(&did).to_string()); - } - let client = NodeClient::new(node, None); - let info: Value = client - .get("/") - .await? - .json() - .await - .context("failed to fetch node info")?; - let did = info["did"].as_str().context("node missing DID")?; - Ok(did.split(':').next_back().unwrap_or(did).to_string()) +/// Resolve the owner short-DID for a bare repo name from the LOCAL identity. +/// Never falls back to the node's own DID (that produced bogus "owned by the +/// node" results for repos that don't exist) — if there's no local identity the +/// caller must pass an explicit `owner/name`. +async fn resolve_owner_did(_node: &str, dir: Option<&std::path::Path>) -> Result { + let kp = load_keypair_from_dir(dir).context( + "no local identity to resolve the repo owner — pass `owner/name`, or run `gl identity new`", + )?; + let did = kp.did().to_string(); + Ok(did.split(':').next_back().unwrap_or(&did).to_string()) } async fn cmd_create( @@ -306,25 +302,28 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { } async fn cmd_clone(name: String, node: String, dir: Option) -> Result<()> { - let did = if let Ok(kp) = load_keypair_from_dir(dir.as_deref()) { - kp.did().to_string() + // Owner is taken from an explicit `owner/name`, else the LOCAL identity — + // never the node's own DID. A missing repo then surfaces as the helper's + // clear 404 rather than a clone under an invented owner. + let (did, repo_name) = if let Some((owner, rest)) = name.split_once('/') { + (owner.to_string(), rest.to_string()) } else { - let client = NodeClient::new(&node, None); - let info: Value = client.get("/").await?.json().await?; - info["did"] - .as_str() - .context("node missing DID")? - .to_string() + let kp = load_keypair_from_dir(dir.as_deref()).context( + "no local identity to resolve the repo owner — pass `owner/name`, or run `gl identity new`", + )?; + (kp.did().to_string(), name) }; - let url = format!("gitlawb://{did}/{name}"); + let url = format!("gitlawb://{did}/{repo_name}"); println!(" cloning {url}"); let status = std::process::Command::new("git") .arg("clone") .arg(&url) + // Point the remote helper at the same node the user selected. + .env("GITLAWB_NODE", &node) .status() .context("failed to run git clone — is git installed?")?; if !status.success() { - anyhow::bail!("git clone failed"); + anyhow::bail!("git clone failed — does the repo exist on this node?"); } Ok(()) } @@ -340,12 +339,24 @@ async fn cmd_info(repo: String, node: String, dir: Option) -> Result<() (short, repo) }; - let r: Value = client - .get(&format!("/api/v1/repos/{owner}/{name}")) - .await? - .json() - .await - .context("repo not found")?; + let resp = client.get(&format!("/api/v1/repos/{owner}/{name}")).await?; + // A non-existent (or unreadable/quarantined) repo is a real 404 from the + // node — surface it plainly instead of printing a stub card with `?` fields + // and a placeholder owner DID. + if !resp.status().is_success() { + if resp.status().as_u16() == 404 { + anyhow::bail!("repository '{owner}/{name}' not found"); + } + let status = resp.status(); + let msg = resp + .json::() + .await + .ok() + .and_then(|v| v["message"].as_str().map(String::from)) + .unwrap_or_else(|| "request failed".to_string()); + anyhow::bail!("repo info failed ({status}): {msg}"); + } + let r: Value = resp.json().await.context("parse repo info")?; let owner_did = r["owner_did"].as_str().unwrap_or(&owner); let gitlawb_url = format!("gitlawb://{owner_did}/{name}"); diff --git a/crates/icaptcha-client/Cargo.toml b/crates/icaptcha-client/Cargo.toml new file mode 100644 index 0000000..c55055a --- /dev/null +++ b/crates/icaptcha-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "icaptcha-client" +version = "0.4.0" # x-release-please-version +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +gitlawb-core = { path = "../gitlawb-core" } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } diff --git a/crates/icaptcha-client/src/lib.rs b/crates/icaptcha-client/src/lib.rs new file mode 100644 index 0000000..d448fcc --- /dev/null +++ b/crates/icaptcha-client/src/lib.rs @@ -0,0 +1,186 @@ +//! Client for the iCaptcha proof-of-intelligence service. +//! +//! The node gates spam-prone writes (repo create / fork / register) behind an +//! iCaptcha proof. This crate implements the *sanctioned client flow*: on a +//! `403 icaptcha_proof_required`, request a challenge for the required level, +//! solve the deterministic computational types locally, obtain the signed +//! proof, and hand it back so the caller can retry the original signed request +//! with the `x-icaptcha-proof` header. +//! +//! `requesterId` is always the caller's DID, so the proof's `sub` claim matches +//! the authenticated signer (the node enforces `sub == authenticated DID`). +//! +//! Blocking HTTP (reqwest::blocking) so the git remote helper can use it +//! directly; `gl` (async) calls it via `tokio::task::spawn_blocking`. + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Deserialize; +use serde_json::json; + +pub mod solvers; + +/// Default iCaptcha service base URL (used when the node doesn't advertise one). +pub const DEFAULT_URL: &str = "https://icaptcha.gitlawb.com"; +/// Default required level (the node's default floor). +pub const DEFAULT_LEVEL: u32 = 3; +/// Header the gated write must echo the proof back in. +pub const PROOF_HEADER: &str = "x-icaptcha-proof"; + +/// Computational challenge types this client solves locally. Restricting the +/// request to these avoids dictionary (anagram/logic) and LLM (wordproblem/ +/// riddle) types, which can't be auto-solved. +const SOLVABLE_TYPES: [&str; 3] = ["arithmetic", "algebra", "sequence"]; + +/// Bound on challenge/answer rounds (the service escalates difficulty on a miss; +/// correct solvers shouldn't escalate, but cap it regardless). +const MAX_ROUNDS: usize = 8; + +/// Where + at what level to solve. `did` becomes the proof's `sub`. +#[derive(Debug, Clone)] +pub struct IcaptchaCfg { + pub url: String, + pub did: String, + pub level: u32, + /// Optional bearer token for an API-key-protected iCaptcha deployment. + pub api_key: Option, +} + +impl IcaptchaCfg { + /// Build config from the caller DID plus optionally-discovered url/level + /// (e.g. the node's `x-icaptcha-url` / `x-icaptcha-level` headers), falling + /// back to defaults. Reads `GITLAWB_ICAPTCHA_API_KEY` for the bearer token. + pub fn new(did: impl Into, url: Option, level: Option) -> Self { + Self { + url: url.unwrap_or_else(|| DEFAULT_URL.to_string()), + did: did.into(), + level: level.unwrap_or(DEFAULT_LEVEL), + api_key: std::env::var("GITLAWB_ICAPTCHA_API_KEY") + .ok() + .filter(|s| !s.is_empty()), + } + } +} + +/// A challenge handed back by the service (mirrors `icaptcha` `Challenge`). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + pub challenge_id: String, + #[serde(rename = "type")] + pub kind: String, + pub difficulty: u32, + pub prompt: String, + pub token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +enum AnswerResult { + Passed { proof: String }, + Continue { challenge: Challenge }, + Failed { reason: String }, +} + +/// Solver callback for types this crate can't solve deterministically. +pub type Solver<'a> = dyn Fn(&Challenge) -> Option + 'a; + +/// Run the full challenge → solve → answer loop and return a fresh proof token. +/// +/// `solver` is consulted for challenge types the built-in solvers don't handle +/// (anagram/logic/LLM); pass `None` to fall back to an interactive stdin prompt. +pub fn obtain_proof(cfg: &IcaptchaCfg, solver: Option<&Solver>) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build iCaptcha http client")?; + + let mut challenge = request_challenge(&client, cfg)?; + for _ in 0..MAX_ROUNDS { + let answer = solvers::solve(&challenge.kind, &challenge.prompt) + .or_else(|| solver.and_then(|s| s(&challenge))) + .or_else(|| interactive_prompt(&challenge)) + .ok_or_else(|| { + anyhow!( + "cannot solve iCaptcha challenge type '{}' automatically; \ + set GITLAWB_ICAPTCHA_API_KEY/solver or solve interactively", + challenge.kind + ) + })?; + + match submit_answer(&client, cfg, &challenge.token, &answer)? { + AnswerResult::Passed { proof } => return Ok(proof), + AnswerResult::Continue { challenge: next } => challenge = next, + AnswerResult::Failed { reason } => bail!("iCaptcha challenge failed: {reason}"), + } + } + bail!("iCaptcha not solved within {MAX_ROUNDS} rounds") +} + +fn request_challenge(client: &reqwest::blocking::Client, cfg: &IcaptchaCfg) -> Result { + let url = format!("{}/v1/challenge", cfg.url.trim_end_matches('/')); + let body = json!({ + "requesterId": cfg.did, + "requiredLevel": cfg.level, + "types": SOLVABLE_TYPES, + }); + let mut req = client.post(&url).json(&body); + if let Some(key) = &cfg.api_key { + req = req.bearer_auth(key); + } + let resp = req.send().with_context(|| format!("POST {url}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + bail!("iCaptcha challenge request failed ({status}): {text}"); + } + resp.json::().context("parse iCaptcha challenge") +} + +fn submit_answer( + client: &reqwest::blocking::Client, + cfg: &IcaptchaCfg, + token: &str, + answer: &str, +) -> Result { + let url = format!("{}/v1/answer", cfg.url.trim_end_matches('/')); + let mut req = client + .post(&url) + .json(&json!({ "token": token, "answer": answer })); + if let Some(key) = &cfg.api_key { + req = req.bearer_auth(key); + } + let resp = req.send().with_context(|| format!("POST {url}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + bail!("iCaptcha answer request failed ({status}): {text}"); + } + resp.json::() + .context("parse iCaptcha answer result") +} + +/// Last-resort fallback: show the prompt and read an answer from the terminal. +/// Returns `None` when stdin isn't a usable interactive source (e.g. an agent), +/// so the caller surfaces a clear "couldn't auto-solve" error instead. +fn interactive_prompt(challenge: &Challenge) -> Option { + use std::io::{stderr, stdin, Write}; + let mut err = stderr(); + let _ = writeln!( + err, + "iCaptcha challenge ({}, level {}): {}\nAnswer: ", + challenge.kind, challenge.difficulty, challenge.prompt + ); + let _ = err.flush(); + let mut line = String::new(); + match stdin().read_line(&mut line) { + Ok(0) | Err(_) => None, + Ok(_) => { + let a = line.trim().to_string(); + if a.is_empty() { + None + } else { + Some(a) + } + } + } +} diff --git a/crates/icaptcha-client/src/solvers.rs b/crates/icaptcha-client/src/solvers.rs new file mode 100644 index 0000000..9dc80ff --- /dev/null +++ b/crates/icaptcha-client/src/solvers.rs @@ -0,0 +1,270 @@ +//! Deterministic solvers for the computational iCaptcha challenge types. +//! +//! Prompt/answer formats mirror the iCaptcha service generators +//! (`icaptcha/src/generators/{arithmetic,algebra,sequence}.ts`). The service +//! grades numerics by value (`Number(a) === Number(b)`), so returning the plain +//! integer string is sufficient. Anagram/logic (need a dictionary/parser) and +//! the LLM types are intentionally NOT solved here — the client requests only +//! these three types and falls back to a hook/interactive prompt otherwise. + +/// Solve a challenge of the given `type` from its prompt. Returns `None` for +/// types we don't solve locally or when the prompt can't be parsed. +pub fn solve(challenge_type: &str, prompt: &str) -> Option { + match challenge_type { + "arithmetic" => solve_arithmetic(prompt).map(|n| n.to_string()), + "algebra" => solve_algebra(prompt).map(|n| n.to_string()), + "sequence" => solve_sequence(prompt).map(|n| n.to_string()), + _ => None, + } +} + +/// `What is 12 + 7 - 3?` -> evaluate the additive chain left to right. +fn solve_arithmetic(prompt: &str) -> Option { + let expr = prompt + .trim() + .strip_prefix("What is ")? + .trim_end_matches('?') + .trim(); + let mut tokens = expr.split_whitespace(); + let mut acc: i64 = tokens.next()?.parse().ok()?; + while let Some(op) = tokens.next() { + let n: i64 = tokens.next()?.parse().ok()?; + match op { + "+" => acc += n, + "-" => acc -= n, + _ => return None, + } + } + Some(acc) +} + +/// `Solve for x: 3x + 4 = 19` (and the `=`-both-sides and `a(x + b)` variants). +/// Parses each side into `coeff*x + const`, then x = (cR - cL) / (aL - aR). +fn solve_algebra(prompt: &str) -> Option { + let eq = prompt.trim().strip_prefix("Solve for x:")?.trim(); + let (lhs, rhs) = eq.split_once('=')?; + let (al, cl) = parse_linear(lhs.trim())?; + let (ar, cr) = parse_linear(rhs.trim())?; + let denom = al - ar; + if denom == 0 { + return None; + } + let num = cr - cl; + if num % denom != 0 { + return None; + } + Some(num / denom) +} + +/// Parse a linear expression in `x` into `(coeff_of_x, constant)`. +/// Handles `Nx`, `x`, integer constants, and a single `N(x ± M)` product, +/// with `+`/`-` separators (the formats the generator emits). +fn parse_linear(s: &str) -> Option<(i64, i64)> { + let s = s.trim(); + // Parenthesized product: a(x ± b) -> coeff a, const a*(±b). + if let Some(open) = s.find('(') { + let a: i64 = s[..open].trim().parse().ok()?; + let close = s.find(')')?; + let inner = &s[open + 1..close]; // "x + 4" / "x - 4" + let mut it = inner.split_whitespace(); + if it.next()? != "x" { + return None; + } + let (coeff_inner, const_inner) = match it.next() { + None => (1, 0), + Some(op) => { + let m: i64 = it.next()?.parse().ok()?; + match op { + "+" => (1, m), + "-" => (1, -m), + _ => return None, + } + } + }; + return Some((a * coeff_inner, a * const_inner)); + } + + // Sum of `±`-separated terms. + let mut coeff = 0i64; + let mut konst = 0i64; + let mut sign = 1i64; + for tok in s.split_whitespace() { + match tok { + "+" => sign = 1, + "-" => sign = -1, + t => { + if let Some(cpart) = t.strip_suffix('x') { + let c: i64 = match cpart { + "" | "+" => 1, + "-" => -1, + _ => cpart.parse().ok()?, + }; + coeff += sign * c; + } else { + let n: i64 = t.parse().ok()?; + konst += sign * n; + } + sign = 1; + } + } + } + Some((coeff, konst)) +} + +/// `What is the next number in this sequence? 2, 4, 6, 8, 10, ?` +fn solve_sequence(prompt: &str) -> Option { + let tail = prompt.split_once("sequence?")?.1; + let nums: Vec = tail + .split(',') + .filter_map(|t| t.trim().parse::().ok()) + .collect(); + if nums.len() < 3 { + return None; + } + next_in_sequence(&nums) +} + +fn next_in_sequence(n: &[i64]) -> Option { + let last = *n.last()?; + + // Arithmetic: constant first difference. + let d = n[1] - n[0]; + if n.windows(2).all(|w| w[1] - w[0] == d) { + return Some(last + d); + } + + // Geometric: constant integer ratio. + if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 { + let r = n[1] / n[0]; + if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) { + return Some(last * r); + } + } + + // Fibonacci-like: each term is the sum of the two before it. + if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) { + return Some(n[n.len() - 1] + n[n.len() - 2]); + } + + // Squares: all perfect squares with consecutive roots. + let roots: Option> = n.iter().map(|&v| isqrt_exact(v)).collect(); + if let Some(roots) = roots { + if roots.windows(2).all(|w| w[1] == w[0] + 1) { + let nr = roots[roots.len() - 1] + 1; + return Some(nr * nr); + } + } + + // Alternating sign over an arithmetic magnitude (generator starts positive). + let signs_alternate = n + .iter() + .enumerate() + .all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 }); + let mags: Vec = n.iter().map(|v| v.abs()).collect(); + let md = mags[1] - mags[0]; + if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) { + let next_mag = mags[mags.len() - 1] + md; + let next_sign = if last >= 0 { -1 } else { 1 }; + return Some(next_sign * next_mag); + } + + None +} + +/// Exact integer square root, or `None` if `v` isn't a perfect square. +fn isqrt_exact(v: i64) -> Option { + if v < 0 { + return None; + } + let r = (v as f64).sqrt().round() as i64; + [r - 1, r, r + 1] + .into_iter() + .find(|&cand| cand >= 0 && cand * cand == v) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arithmetic_chains() { + assert_eq!( + solve("arithmetic", "What is 12 + 7 - 3?").as_deref(), + Some("16") + ); + assert_eq!(solve("arithmetic", "What is 5?").as_deref(), Some("5")); + assert_eq!( + solve("arithmetic", "What is 100 - 40 - 35 + 2?").as_deref(), + Some("27") + ); + } + + #[test] + fn algebra_linear() { + assert_eq!( + solve("algebra", "Solve for x: 3x + 4 = 19").as_deref(), + Some("5") + ); + assert_eq!( + solve("algebra", "Solve for x: 5x - 8 = 12").as_deref(), + Some("4") + ); + // both sides: 2x + 16 = 5x - 8 -> 3x = 24 -> x=8 + assert_eq!( + solve("algebra", "Solve for x: 2x + 16 = 5x - 8").as_deref(), + Some("8") + ); + // parenthesized: 3(x - 4) = 9 -> x-4=3 -> x=7 + assert_eq!( + solve("algebra", "Solve for x: 3(x - 4) = 9").as_deref(), + Some("7") + ); + // negative solution: 2x + 10 = 4 -> x=-3 + assert_eq!( + solve("algebra", "Solve for x: 2x + 10 = 4").as_deref(), + Some("-3") + ); + } + + #[test] + fn sequence_patterns() { + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 2, 4, 6, 8, 10, ?" + ) + .as_deref(), + Some("12") + ); + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 3, 6, 12, 24, 48, ?" + ) + .as_deref(), + Some("96") + ); + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 1, 4, 9, 16, 25, ?" + ) + .as_deref(), + Some("36") + ); + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 1, 1, 2, 3, 5, ?" + ) + .as_deref(), + Some("8") + ); + } + + #[test] + fn unsupported_types_return_none() { + assert_eq!(solve("anagram", "Unscramble: tca"), None); + assert_eq!(solve("riddle", "What has keys but no locks?"), None); + } +}