-
Notifications
You must be signed in to change notification settings - Fork 22
feat(gl): sanctioned iCaptcha client flow + secure git lifecycle #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.rsRepository: 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 80Repository: Gitlawb/node Length of output: 345 Avoid cloning the full pack body here. 🤖 Prompt for AI Agents |
||
| .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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/srcRepository: 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.rsRepository: Gitlawb/node Length of output: 16017 🏁 Script executed: #!/bin/bash
rg -n 'obtain_proof_noninteractive|noninteractive|interactive_prompt|obtain_proof\(' cratesRepository: Gitlawb/node Length of output: 865 Disable interactive iCaptcha prompting in the remote helper. 🤖 Prompt for AI Agents |
||
| 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()); | ||
|
|
||
There was a problem hiding this comment.
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 pushis 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
📝 Committable suggestion
🤖 Prompt for AI Agents