Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/gl",
"crates/git-remote-gitlawb",
"crates/gitlawb-attest",
"crates/icaptcha-client",
]

[workspace.package]
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/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
Comment on lines +217 to +219

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the push behavior.

git push is signed, but the helper still has a 403 iCaptcha retry path. Saying there is “no per-push challenge” reads as an absolute guarantee that the implementation doesn’t make.

📝 Suggested wording
-  git push is **signed-only** (owner signature is the gate — no per-push challenge);
+  git push is **signed** and will transparently retry if the node returns an iCaptcha challenge;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **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
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
`git push` is **signed** and will transparently retry if the node returns an iCaptcha challenge;
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 217 - 219, Clarify the `git push` wording in
README.md so it does not imply an absolute “no per-push challenge” guarantee.
Update the sentence in the access-policy section near the `create / fork /
register` and `git push` description to state that pushes are signed-only by
default while the helper may still retry on 403 iCaptcha responses, using the
same policy terms already used elsewhere in the document.

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
Expand Down
1 change: 1 addition & 0 deletions crates/git-remote-gitlawb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
101 changes: 73 additions & 28 deletions crates/git-remote-gitlawb/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IcaptchaCfg> {
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<String> = std::env::args().collect();

Expand Down Expand Up @@ -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<String> = 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()
Comment on lines +329 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'reqwest\s*=|bytes\s*=' Cargo.toml crates -g 'Cargo.toml'

Repository: Gitlawb/node

Length of output: 586


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Cargo.toml excerpts =="
sed -n '1,120p' Cargo.toml

echo
echo "== git-remote-gitlawb main.rs around request body =="
sed -n '280,380p' crates/git-remote-gitlawb/src/main.rs

echo
echo "== sign_request definition =="
rg -n 'fn sign_request|sign_request\(' crates/git-remote-gitlawb/src/main.rs

Repository: Gitlawb/node

Length of output: 6122


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== reqwest body API docs from local cargo registry if available =="
python3 - <<'PY'
import os, glob, textwrap, pathlib, sys
home = pathlib.Path.home()
candidates = list(home.glob('.cargo/registry/src/*/reqwest-0.12.*'))
print("\n".join(str(p) for p in candidates[:20]))
PY

echo
echo "== search local registry for Bytes body support =="
rg -n 'impl From<Bytes>|From<Bytes>|body<B: Into<Body>>|struct Body' ~/.cargo/registry/src -g '*.rs' | head -n 80

Repository: Gitlawb/node

Length of output: 345


Avoid cloning the full pack body here.
request_body.clone() duplicates the entire pack before every send, so large pushes pay the extra peak-memory cost even on the first attempt. A shared body type like bytes::Bytes would avoid the copy and still allow retries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/git-remote-gitlawb/src/main.rs` around lines 329 - 333, The push
request in main currently clones the entire request_body before sending, which
adds unnecessary peak-memory overhead; update the send path to use a shared body
representation instead of duplicating the pack. Refactor the request_body
handling in main around req.body(...).send() to use a cloneable shared type such
as bytes::Bytes so retries still work without copying the full payload.

.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)?);
Comment on lines +340 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C 6 'fn interactive_prompt|interactive_prompt|obtain_proof' crates/icaptcha-client/src crates/git-remote-gitlawb/src

Repository: Gitlawb/node

Length of output: 4208


🏁 Script executed:

#!/bin/bash
sed -n '1,240p' crates/git-remote-gitlawb/src/main.rs
printf '\n--- icaptcha-client ---\n'
sed -n '1,240p' crates/icaptcha-client/src/lib.rs

Repository: Gitlawb/node

Length of output: 16017


🏁 Script executed:

#!/bin/bash
rg -n 'obtain_proof_noninteractive|noninteractive|interactive_prompt|obtain_proof\(' crates

Repository: Gitlawb/node

Length of output: 865


Disable interactive iCaptcha prompting in the remote helper. icaptcha_client::obtain_proof(&cfg, None) still falls back to interactive_prompt, which reads from stdin(); here that stdin is the Git remote-helper protocol stream, so a challenge prompt can hang or corrupt the exchange. Use a non-interactive proof path here and fail with an actionable error instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/git-remote-gitlawb/src/main.rs` around lines 340 - 342, The remote
helper path in main should not call icaptcha_client::obtain_proof with a
fallback that can reach interactive_prompt, since this code runs over the Git
protocol stream. Update the proof acquisition flow in the icaptcha challenge
handling block to use a non-interactive proof source only, and if no proof can
be obtained, return an actionable error instead of prompting on stdin.

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());
Expand Down
49 changes: 38 additions & 11 deletions crates/gitlawb-node/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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()),
Expand Down
31 changes: 24 additions & 7 deletions crates/gitlawb-node/src/icaptcha.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -265,10 +269,23 @@ pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppE
}

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
))
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
Expand Down
1 change: 1 addition & 0 deletions crates/gl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading