Skip to content

Commit 06388ec

Browse files
kevincodex1claude
andauthored
feat(gl): sanctioned iCaptcha client flow + secure git lifecycle (#138)
* feat(gl): sanctioned iCaptcha client flow + secure git lifecycle Builds the client side of the iCaptcha gate so a legitimate agent passes the challenge transparently, with no manual headers and no GITLAWB_ICAPTCHA_PROOF env var. Server enforcement is unchanged. New crate `icaptcha-client` (blocking, shared by gl + the git helper): - deterministic solvers for the computational challenge types (arithmetic, algebra, sequence) matching the iCaptcha generators; anagram/logic/LLM fall back to a solver hook / interactive prompt. - obtain_proof(): POST /v1/challenge (requesterId = caller DID), solve, POST /v1/answer, handle escalation, return the signed proof. Optional bearer auth via GITLAWB_ICAPTCHA_API_KEY. gl (crates/gl): - http.rs: unify post/put/delete through one signed sender that, on a 403 iCaptcha challenge (detected via x-icaptcha-url / x-icaptcha-level headers), solves it and retries the same signed request with the x-icaptcha-proof header (bounded retry absorbs the ~5 min proof TTL). Removes the env hack. Emits an actionable hint on 401 not_an_agent (old-CLI / unregistered). - repo.rs: `repo info` checks status and returns a real 404 instead of a stub card with `?` fields; `repo info`/`clone` resolve the owner from the local identity or an explicit owner/name — never the node's own DID. - doctor.rs: warn on gl-vs-node version drift; add an iCaptcha reachability check. git-remote-gitlawb: same shared client — push stays signed (RFC 9421); on a 403 iCaptcha (safety net; push is signed-only, not gated) it solves and retries. Clone/fetch unsigned; missing repo keeps its clear 404. gitlawb-node: the 403 icaptcha_proof_required response now advertises the service url + required level as JSON fields and x-icaptcha-url / x-icaptcha-level headers (mirroring the human_detected pattern) so clients discover them instead of scraping the message. The sub == authenticated-DID and expiry/level checks are unchanged — enforcement is not weakened. README: documents the full lifecycle (identity -> register -> repo create with auto-iCaptcha -> push -> clone), the requesterId == DID rule, and proof TTL. Tested: solver unit tests; full workspace green against Postgres (node still rejects missing/expired/wrong-subject proofs). * fix(icaptcha-client,gl): address PR #138 review feedback Rebased onto main (resolving the git-remote-gitlawb/main.rs conflict from #119) and addressed the review findings: - [beardthelion P2 / jatmn P2 / CodeRabbit] Removed the dead iCaptcha retry loop from the push path. git_receive_pack never calls verify_request (only create/fork/register are iCaptcha-gated), so a push 403 never carries x-icaptcha-* headers and the loop could never solve. Taking main's push path (via #119) deletes it, which also resolves the pack-clone (retries no longer copy the whole pack) and the interactive-prompt-over-git-stream threads, and restores the sanitized info/refs error path. git-remote-gitlawb no longer depends on icaptcha-client. - [beardthelion P1] Sanitize and length-bound iCaptcha error bodies before they reach the terminal. The origin is only as trusted as the node that advertised it, so its non-2xx bodies (and Failed{reason}) are attacker-influenceable; they went raw into bail! (CWE-150, the #137 class). Now bounded-read + C0/C1-stripped + capped, same shape as #137. - [beardthelion P2] Do not trust or hand credentials to a node-chosen iCaptcha URL. x-icaptcha-url was used as the base with no scheme/host check and the API key was sent to it on the first hop (SSRF + key exfiltration). resolve_solver_url now honors an advertised URL only when https + host-allowlisted (public default or the operator's GITLAWB_ICAPTCHA_URL), else falls back to the trusted origin; the API key is attached only to the operator's own configured origin. - [jatmn P2 / CodeRabbit] gl repo clone with a bare name now derives the short owner key via resolve_owner_did, matching the other commands' gitlawb://z.../name URL shape instead of the colon-bearing full DID. Skipped (with reason): jatmn P3 (doctor GITLAWB_ICAPTCHA_URL) is resolved by the P2 change — that env var is now read by the write path, so the doctor's probe is accurate. CodeRabbit User-Agent-version nit lived in the push code reverted to main. README push wording kept ("signed-only, no per-push challenge") — now accurate since the retry path is gone; the API-key bullet was updated for the new trust model. Tests: added sanitize + resolve_solver_url unit tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2df6ff9 commit 06388ec

12 files changed

Lines changed: 989 additions & 84 deletions

File tree

‎Cargo.lock‎

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

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ members = [
66
"crates/gl",
77
"crates/git-remote-gitlawb",
88
"crates/gitlawb-attest",
9+
"crates/icaptcha-client",
910
]
1011

1112
[workspace.package]

‎README.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,45 @@ git clone gitlawb://did:key:z6Mk.../my-repo
185185

186186
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
187187

188+
### Full lifecycle against an iCaptcha-enforcing node
189+
190+
Public nodes (e.g. `node.gitlawb.com`) require two things on writes:
191+
192+
1. **RFC 9421 HTTP Signatures** — every write is signed by your identity key. `gl`
193+
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
194+
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
195+
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
196+
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
197+
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
198+
locally (arithmetic / algebra / sequence), and **retries the same signed request**
199+
with the `x-icaptcha-proof` header — no manual steps, no env vars.
200+
201+
```bash
202+
gl identity new # create did:key identity
203+
gl register --node https://node.gitlawb.com # signed + auto-solves iCaptcha
204+
gl repo create memlawb --node https://node.gitlawb.com # signed + auto-solves iCaptcha
205+
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
206+
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
207+
gl doctor # preflight: identity, node, version, iCaptcha
208+
```
209+
210+
Notes:
211+
212+
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
213+
authenticated signer; `gl`/helper set this automatically and the node enforces
214+
`sub == authenticated DID` (so a proof minted for another identity is rejected).
215+
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
216+
solving and use, the client transparently solves a fresh one and retries.
217+
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
218+
`git push` is **signed-only** (owner signature is the gate — no per-push challenge);
219+
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
220+
clear `404`, never a placeholder.
221+
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
222+
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
223+
`https` origin whose host is allowlisted (that URL or the public default), and
224+
sends the bearer token **only** to your configured origin — never to a URL a
225+
node advertises — so a hostile node can't capture the key or redirect the solve.
226+
188227
---
189228

190229
## Architecture

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ pub enum AppError {
2323
#[allow(dead_code)]
2424
Forbidden(String),
2525

26-
#[error("icaptcha proof required: {0}")]
27-
IcaptchaProofRequired(String),
26+
#[error("icaptcha proof required: {message}")]
27+
IcaptchaProofRequired {
28+
message: String,
29+
/// iCaptcha service base URL the client should solve against.
30+
url: String,
31+
/// Minimum proof level this node requires.
32+
level: u32,
33+
},
2834

2935
#[error("invalid request: {0}")]
3036
BadRequest(String),
@@ -44,6 +50,34 @@ pub enum AppError {
4450

4551
impl IntoResponse for AppError {
4652
fn into_response(self) -> Response {
53+
// iCaptcha challenges carry structured discovery so clients don't have to
54+
// scrape the message: the service URL and required level are returned as
55+
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
56+
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
57+
if let AppError::IcaptchaProofRequired {
58+
message,
59+
url,
60+
level,
61+
} = &self
62+
{
63+
use axum::http::HeaderValue;
64+
let body = Json(json!({
65+
"error": "icaptcha_proof_required",
66+
"message": message,
67+
"icaptcha_url": url,
68+
"required_level": level,
69+
}));
70+
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
71+
let headers = resp.headers_mut();
72+
if let Ok(v) = HeaderValue::from_str(url) {
73+
headers.insert("x-icaptcha-url", v);
74+
}
75+
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
76+
headers.insert("x-icaptcha-level", v);
77+
}
78+
return resp;
79+
}
80+
4781
let (status, code, message) = match &self {
4882
AppError::RepoNotFound(r) => (
4983
StatusCode::NOT_FOUND,
@@ -58,15 +92,8 @@ impl IntoResponse for AppError {
5892
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
5993
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
6094
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
61-
// 403, not 401: the caller IS an authenticated agent (credentials are
62-
// valid) but is forbidden from this action without a valid, fresh
63-
// iCaptcha proof. The distinct `icaptcha_proof_required` code — which
64-
// clients branch on — keeps it separable from a plain `forbidden`.
65-
AppError::IcaptchaProofRequired(msg) => (
66-
StatusCode::FORBIDDEN,
67-
"icaptcha_proof_required",
68-
msg.clone(),
69-
),
95+
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
96+
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
7097
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
7198
AppError::TooManyRequests(msg) => {
7299
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,13 @@ impl ProofGuard {
237237
match self.0 {
238238
Some(p) => {
239239
if !db.consume_proof_jti(&p.jti, p.exp).await? {
240-
return Err(AppError::IcaptchaProofRequired(
241-
"iCaptcha proof already used (replay); solve a fresh challenge".to_string(),
242-
));
240+
let (url, level) = url_and_level();
241+
return Err(AppError::IcaptchaProofRequired {
242+
message: "iCaptcha proof already used (replay); solve a fresh challenge"
243+
.to_string(),
244+
url,
245+
level,
246+
});
243247
}
244248
Ok(Some(p))
245249
}
@@ -265,10 +269,23 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppE
265269
}
266270

267271
fn reject_error(v: &Verifier, reason: &str) -> AppError {
268-
AppError::IcaptchaProofRequired(format!(
269-
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
270-
v.url, v.required_level, PROOF_HEADER
271-
))
272+
AppError::IcaptchaProofRequired {
273+
message: format!(
274+
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
275+
v.url, v.required_level, PROOF_HEADER
276+
),
277+
url: v.url.clone(),
278+
level: v.required_level,
279+
}
280+
}
281+
282+
/// iCaptcha service url + required level for error responses, read from the
283+
/// initialized verifier (falls back to defaults if somehow uninitialized).
284+
fn url_and_level() -> (String, u32) {
285+
VERIFIER
286+
.get()
287+
.map(|v| (v.url.clone(), v.required_level))
288+
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
272289
}
273290

274291
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it

‎crates/gl/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ path = "src/main.rs"
1212

1313
[dependencies]
1414
gitlawb-core = { path = "../gitlawb-core" }
15+
icaptcha-client = { path = "../icaptcha-client" }
1516
tokio = { workspace = true }
1617
serde = { workspace = true }
1718
serde_json = { workspace = true }

‎crates/gl/src/doctor.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
174174
"node",
175175
format!("{} — v{version} ({short_did}…)", args.node),
176176
));
177+
// Capability drift: a node newer than this CLI may require features
178+
// (RFC 9421 signing, iCaptcha) the CLI doesn't speak.
179+
let gl_ver = env!("CARGO_PKG_VERSION");
180+
if version != "?" && is_newer(version, gl_ver) {
181+
checks.push(Check::warn(
182+
"gl version",
183+
format!(
184+
"node is v{version} but gl is v{gl_ver} — your CLI may be missing \
185+
features (signing / iCaptcha) this node requires"
186+
),
187+
"upgrade gl: curl -sSf https://gitlawb.com/install.sh | sh",
188+
));
189+
}
177190
}
178191
Ok(resp) => {
179192
checks.push(Check::fail(
@@ -191,6 +204,31 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
191204
}
192205
}
193206

207+
// ── 4b. iCaptcha capability ───────────────────────────────────────────
208+
// Gated writes (repo create / register / fork) auto-solve a challenge at the
209+
// iCaptcha service; check it's reachable so the failure mode is obvious.
210+
let icaptcha_url = std::env::var("GITLAWB_ICAPTCHA_URL")
211+
.unwrap_or_else(|_| icaptcha_client::DEFAULT_URL.to_string());
212+
match NodeClient::new(&icaptcha_url, None).get("/v1/pubkey").await {
213+
Ok(resp) if resp.status().is_success() => {
214+
checks.push(Check::pass("iCaptcha", format!("{icaptcha_url} reachable")));
215+
}
216+
Ok(resp) => {
217+
checks.push(Check::warn(
218+
"iCaptcha",
219+
format!("{icaptcha_url} returned HTTP {}", resp.status()),
220+
"gated writes (repo create / register) may fail until iCaptcha is reachable",
221+
));
222+
}
223+
Err(e) => {
224+
checks.push(Check::warn(
225+
"iCaptcha",
226+
format!("{icaptcha_url} unreachable: {e}"),
227+
"set GITLAWB_ICAPTCHA_URL or check connectivity — repo create / register solve a challenge there",
228+
));
229+
}
230+
}
231+
194232
// ── 5. git-remote-gitlawb helper ──────────────────────────────────────
195233
// Use PATH lookup only — invoking the binary directly triggers git internals
196234
// that error with "fatal: not a git repository" outside of a git repo.

0 commit comments

Comments
 (0)