Compare commits

..
Author SHA1 Message Date
beardthelion 012bdb9df0 fix(gl): land the outstanding review asks on the icaptcha-proof flag
- help text on --icaptcha-proof so Clap renders what the flag accepts
- register_forwards_icaptcha_proof_header pins the supplied proof onto
  the register request as x-icaptcha-proof
- revert the 186 file-mode flips the contributor commits carried
  (644->755 noise unrelated to the feature)
- the redirect-forwarding concern is already covered on main: the
  client runs a same-origin-only redirect policy, so the proof header
  can never ride a redirect to a different origin; the cross-origin
  and proof-header tests already exist in http.rs
2026-11-10 22:24:28 -06:00
beardthelion 76891277e4 Merge remote-tracking branch 'origin/main' into pr-193 2026-11-10 22:14:17 -06:00
jilt 4652acfe26 gl: disable redirects for signed/proof requests, add end-to-end test 2026-07-16 22:31:29 +02:00
jilt 9e8a88abe5 gl: disable redirects, add proof test, improve error context
- Disable HTTP redirects in NodeClient to avoid leaking proof/signature headers
- Add send_signed_does_not_follow_redirect test
- Use specific error context for proof-bearing registration requests
- Add register_forwards_icaptcha_proof_header test
2026-07-14 12:32:02 +02:00
root d454de49ac gl: add --icaptcha-proof flag to register command
This allows users to provide a pre-obtained iCaptcha proof token when
registering with a node, sent as the x-icaptcha-proof header on the
initial POST /api/register request.

Usage:
  gl register --icaptcha-proof "<token>"
  GITLAWB_ICAPTCHA_PROOF="<token>" gl register

The existing automatic iCaptcha retry flow (403 -> solve -> retry) remains
unchanged and is still available if no initial proof is provided.
2026-07-12 00:36:40 +02:00
3 changed files with 70 additions and 121 deletions

No files matched your search

-119
View File
@@ -1,119 +0,0 @@
# Gitlawb Wire Protocol Specification (v1, alpha)
This document describes the wire behavior a second implementation can interop
with **today**. Anything the node does not ship yet is explicitly marked
*planned*; nothing below is aspirational unless it says so.
## 1. Identity & DIDs
- **Key type:** Ed25519.
- **Method (alpha):** `did:key` only. Resolution is deterministic public-key
extraction from the multicodec prefix (`z6Mk…`, multicodec `0xed01`).
- Signed requests presenting any other DID method are rejected; the node's
auth layer answers with a hint that only `did:key` is supported in alpha.
- *Planned:* `did:web` and `did:gitlawb` resolution. Clients MUST NOT assume
either is accepted anywhere yet.
## 2. Authentication (RFC 9421)
- **Transport:** production deployments should run the node behind TLS
(typically a reverse proxy). The node itself has no scheme check: it verifies
RFC 9421 headers on whatever transport the request arrives over, so nothing
at the signature layer refuses a cleartext request.
- **HTTP signatures:** `Signature-Input` covers `@method`, `@path`, and
`content-digest`:
`sig1=("@method" "@path" "content-digest");keyid="did:key:…";alg="ed25519";created=<unix>`
All three components are mandatory: a `Signature-Input` missing one is
rejected as `incomplete_signature`.
- **`@path` value:** the node binds `@path` to the path *and* query string of
the request, not to the path-only value RFC 9421 defines for `@path`. A
second implementation must sign the request exactly as it goes on the
wire, e.g. `GET /zOwner/myrepo.git/info/refs?service=git-upload-pack`
including the `?service=…` part (see §5 for the owner and `.git`
normalization).
- **Signer identity:** `keyid` is the actor DID (`did:key` only, see §1).
- **Integrity:** `Content-Digest` is SHA-256 over the request body and is
verified against it. Every signed request MUST send the header: a signed
request without it is refused `400 missing_content_digest` before routing.
On a body-less request the header still carries the SHA-256 digest of the
empty body (`sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:`),
it is never omitted.
## 3. Proof of Intelligence (iCaptcha)
- **Mode:** controlled by `ICAPTCHA_MODE` = `off` | `shadow` | `enforce`,
**default `off`**. A node with the gate off never demands proof.
- **Scope:** the gate applies to a small set of write endpoints (agent
registration and repo create/fork), not to all writes.
- **Wire shape:** the client-supplied header is `x-icaptcha-proof` — that is
the only iCaptcha header a node reads from a request. When the gate is
`enforce` and a verifier public key is loaded, a missing or invalid proof
answers `403 icaptcha_proof_required` and sets `x-icaptcha-url` and
`x-icaptcha-level` **on the response** (also mirrored as JSON fields) so
the client can go solve a challenge and retry. Fail-safe: `enforce` with
no public key loaded (unset and unfetchable) stays inert and admits the
request rather than rejecting every request.
## 4. Ref-Update Certificate
*Planned:* the `gitlawb/ref-update/v1` schema below is defined in code, but no
shipped endpoint serves or verifies these certificates today; nothing in
sections 2-3 produces one on the wire. It is documented here so a second
implementation shares the type vocabulary ahead of the wire surface.
The hash fields reserve 64 hex characters for the SHA-256 object format; the
repositories the node serves are sha1 with 40-hex OIDs (see §5), so the field
width is a forward reservation, not what a current push produces.
Two distinct certificate-shaped payloads exist; this section documents the
**core signing schema**, which is what `gitlawb/ref-update/v1` names:
- **Type string:** `gitlawb/ref-update/v1`.
- **Body (canonical JSON, the signed bytes):** `type`, `repo` (the
repository DID), `ref_name`, `from` (previous hash, 64 hex chars,
all-zeros for a new ref), `to` (target hash, 64 hex chars), `seq`
(monotonically increasing, replay prevention), `timestamp` (RFC 3339),
`nonce`.
- **Signatures:** a list of `{signer: did:key, sig: base64url-unpadded
Ed25519}` entries appended outside the signed body; thresholds are counted
over distinct signers that verify.
The node's REST API separately serves **push receipts** — a different,
node-issued JSON payload attesting that a push was processed. Receipts are
not `gitlawb/ref-update/v1` documents and are not interchangeable with them;
they are documented with the node's HTTP API, not here.
## 5. Storage & Git Transport
- **Object format:** bare repositories are created with
`--object-format=sha1`; production OIDs are 40 hex chars. *Planned:*
SHA-256 object format.
- **Path normalization:** `{repo}` may carry one trailing `.git` suffix,
stripped before lookup (so `myrepo.git` resolves to `myrepo`; a repo
literally named `foo.git` addresses as `foo.git.git`). `{owner}` may be the
full `did:key:…` string or the bare multibase segment the `did:key:` prefix
implies; both resolve to the same owner. None of this changes `@path`:
sign the request path and query exactly as sent.
- **Smart-HTTP endpoints:**
- `GET /{owner}/{repo}/info/refs?service=git-upload-pack` — fetch
advertisement.
- `GET /{owner}/{repo}/info/refs?service=git-receive-pack` — push
advertisement.
The `?service=` parameter is mandatory and takes exactly those two
values; a missing parameter is a `400`, not an omitted-service default.
- `POST /{owner}/{repo}/git-upload-pack` — clone/fetch. Request and
response bodies are pkt-line framed; the result content type is
`application/x-git-upload-pack-result`.
- `POST /{owner}/{repo}/git-receive-pack` — push. **Authentication is
mandatory**: an unsigned push is refused with `401`. Same pkt-line
framing; the result content type is
`application/x-git-receive-pack-result`.
Signed requests on this path follow §2, including TLS transport.
- **Fetch vs push auth split:** fetch-side requests against a withheld or
private repo answer `404` (existence is not confirmed to unauthorized
callers), while the push path answers `401` for missing/invalid
signatures. A second implementation must not treat the two symmetrically.
- **Content addressing:** the node records IPFS CID metadata per branch and
serves it over REST (the repo `refs` endpoint maps branch heads to CIDs).
*Planned:* IPNS-backed branch pointers; IPNS is not part of the shipped
wire contract.
+30 -1
View File
@@ -148,6 +148,18 @@ impl NodeClient {
self.send_signed("POST", path, body).await
}
/// POST with JSON body + RFC 9421 signing and an optional user-provided
/// iCaptcha proof, sent on the initial request.
pub async fn post_with_proof(
&self,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
self.send_signed_with_proof("POST", path, body, initial_proof)
.await
}
/// PUT with RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("PUT", path, body).await
@@ -162,16 +174,32 @@ impl NodeClient {
/// `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).
/// Existing call path: no user-supplied proof on the first request.
async fn send_signed(
&self,
method: &str,
path: &str,
body: &[u8],
) -> Result<reqwest::Response> {
let mut proof: Option<String> = None;
self.send_signed_with_proof(method, path, body, None).await
}
/// Sign + send a write. `initial_proof`, if provided, is attached to the
/// first request. On a 403 iCaptcha challenge advertised via
/// `x-icaptcha-*` headers, solve and retry up to MAX_ICAPTCHA_RETRIES.
async fn send_signed_with_proof(
&self,
method: &str,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
let mut proof = initial_proof.map(str::to_owned);
let mut attempts = 0;
loop {
let resp = self.send_once(method, path, body, proof.as_deref()).await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED
@@ -195,6 +223,7 @@ impl NodeClient {
continue;
}
}
return Ok(resp);
}
}
+40 -1
View File
@@ -22,6 +22,11 @@ pub struct RegisterArgs {
)]
pub node: String,
/// A pre-obtained iCaptcha proof token, forwarded on the register request
/// instead of making the client solve a fresh challenge.
#[arg(long, env = "GITLAWB_ICAPTCHA_PROOF")]
pub icaptcha_proof: Option<String>,
/// Capabilities to advertise (comma-separated)
#[arg(
long,
@@ -56,7 +61,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
}))?;
let resp = client
.post("/api/register", &body)
.post_with_proof("/api/register", &body, args.icaptcha_proof.as_deref())
.await
.context("failed to connect to node")?;
@@ -160,6 +165,7 @@ mod tests {
capabilities: vec!["git:push".to_string(), "git:fetch".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await
.unwrap();
@@ -173,6 +179,36 @@ mod tests {
assert_eq!(content["node"].as_str().unwrap(), server.url());
}
/// A supplied `--icaptcha-proof` must ride the register request as the
/// `x-icaptcha-proof` header so an enforce-mode node accepts it without a
/// fresh solve round-trip.
#[tokio::test]
async fn register_forwards_icaptcha_proof_header() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.match_header("x-icaptcha-proof", "test.supplied.proof")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#)
.create_async()
.await;
run(RegisterArgs {
node: server.url(),
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: Some("test.supplied.proof".to_string()),
})
.await
.unwrap();
m.assert();
}
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
/// silent split-brain: registration prints success, and every command that later
@@ -201,6 +237,7 @@ mod tests {
capabilities: vec!["git:push".to_string()],
model: None,
dir: None,
icaptcha_proof: None,
})
.await
.unwrap();
@@ -235,6 +272,7 @@ mod tests {
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;
@@ -256,6 +294,7 @@ mod tests {
capabilities: vec![],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;