Skip to content
Closed
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
44 changes: 26 additions & 18 deletions crates/git-remote-gitlawb/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,22 @@ fn handle_connect(
let refs_url = format!("{}/info/refs?service={}", repo_base, service);
tracing::debug!("GET {refs_url}");

let refs_resp = client
let mut refs_req = client
.get(&refs_url)
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
.send()
.with_context(|| format!("GET {refs_url}"))?;
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0");

// Sign the read so the node can authorize private-repo fetches (RFC 9421).
// GET has no body, so content-digest covers the empty body. Harmless for
// public repos — the node verifies only when the headers are present.
if let Some(kp) = keypair {
let signed = sign_request(kp, "GET", &url_path(&refs_url), &[]);
refs_req = refs_req
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
}

let refs_resp = refs_req.send().with_context(|| format!("GET {refs_url}"))?;

if !refs_resp.status().is_success() {
bail!(
Expand Down Expand Up @@ -201,20 +212,17 @@ fn handle_connect(
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
.body(request_body.clone());

// 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)"
);
}
// Sign both push (receive-pack) and fetch (upload-pack). Fetch signing lets
// the node authorize private-repo clones; ignored by the node for public repos.
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 if service == "git-receive-pack" {
tracing::warn!("no identity keypair found — push will be unsigned (v0.1 local alpha only)");
}

let pack_resp = req.send().with_context(|| format!("POST {post_url}"))?;
Expand Down
121 changes: 121 additions & 0 deletions crates/gitlawb-node/src/api/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,47 @@ pub async fn get_tree(

// ── Git smart HTTP endpoints ──────────────────────────────────────────────

/// Authorize a read (clone/fetch) against a repo's visibility.
///
/// Public repos are world-readable. Private repos require the caller's
/// *authenticated* DID (verified upstream by `optional_signature`) to match the
/// owner. Returns `RepoNotFound` (404, NOT 403) when unauthorized so the node
/// does not leak the existence of a private repo.
///
/// Whole-repo (`is_public`) only. Path/package-scoped visibility is a follow-up
/// that needs a `visibility_rules` table and a way to filter subtrees out of the
/// pack — see issue discussion.
fn authorize_read(
record: &crate::db::RepoRecord,
auth: Option<&AuthenticatedDid>,
owner: &str,
name: &str,
) -> Result<()> {
if record.is_public {
return Ok(());
}
let caller = match auth {
Some(AuthenticatedDid(did)) => did.as_str(),
None => return Err(AppError::RepoNotFound(format!("{owner}/{name}"))),
};
// Mirror the owner-match idiom from api/protect.rs (full did:key vs short form).
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(record.owner_did.as_str());
if caller != record.owner_did.as_str() && caller != owner_short {
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
}
Ok(())
}

/// GET /:owner/:repo.git/info/refs?service=git-upload-pack
pub async fn git_info_refs(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(query): Query<InfoRefsQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Response> {
let name = repo.trim_end_matches(".git");
tracing::info!(owner = %owner, repo = %name, "info/refs request");
Expand All @@ -322,6 +358,13 @@ pub async fn git_info_refs(
.ok_or_else(|| AppError::BadRequest("missing ?service= parameter".into()))?;
tracing::debug!(service = %service, repo = %name, "info/refs service");

// Enforce read (clone/fetch) visibility. The push advertisement
// (service=git-receive-pack) is authorized separately on the
// git-receive-pack POST, so leave it untouched here.
if service == "git-upload-pack" {
authorize_read(&record, auth.as_ref().map(|e| &e.0), &owner, name)?;
}

// For receive-pack (push), download the latest from Tigris so the client
// sees the same refs that acquire_write() will operate on.
let disk_path = if service == "git-receive-pack" {
Expand Down Expand Up @@ -352,6 +395,7 @@ pub async fn git_info_refs(
pub async fn git_upload_pack(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
body: Bytes,
) -> Result<Response> {
let name = repo.trim_end_matches(".git");
Expand All @@ -361,6 +405,8 @@ pub async fn git_upload_pack(
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;

authorize_read(&record, auth.as_ref().map(|e| &e.0), &owner, name)?;

let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
Expand Down Expand Up @@ -1066,3 +1112,78 @@ fn to_response(record: &crate::db::RepoRecord, state: &AppState, star_count: i64
forked_from: record.forked_from.clone(),
}
}

#[cfg(test)]
mod tests {
use super::*;

/// A repo owned by `did:key:z6MkOWNER` with the given visibility.
fn repo(is_public: bool) -> crate::db::RepoRecord {
crate::db::RepoRecord {
id: "r1".into(),
name: "secret".into(),
owner_did: "did:key:z6MkOWNER".into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: "/tmp/x".into(),
forked_from: None,
machine_id: None,
}
}

fn did(s: &str) -> AuthenticatedDid {
AuthenticatedDid(s.to_string())
}

#[test]
fn public_repo_allows_anonymous() {
authorize_read(&repo(true), None, "owner", "secret").unwrap();
}

#[test]
fn public_repo_allows_any_authenticated_did() {
let other = did("did:key:z6MkOTHER");
authorize_read(&repo(true), Some(&other), "owner", "secret").unwrap();
}

#[test]
fn private_repo_allows_owner() {
let owner = did("did:key:z6MkOWNER");
authorize_read(&repo(false), Some(&owner), "owner", "secret").unwrap();
}

#[test]
fn private_repo_denies_anonymous() {
let err = authorize_read(&repo(false), None, "owner", "secret").unwrap_err();
assert!(
matches!(err, AppError::RepoNotFound(_)),
"anonymous read of a private repo must 404 (no-leak), got {err:?}"
);
}

#[test]
fn private_repo_denies_non_owner() {
let other = did("did:key:z6MkOTHER");
let err = authorize_read(&repo(false), Some(&other), "owner", "secret").unwrap_err();
assert!(
matches!(err, AppError::RepoNotFound(_)),
"non-owner read of a private repo must 404, not 403, got {err:?}"
);
}

/// The denial must be byte-identical to a genuinely-missing repo so a private
/// repo's existence cannot be inferred. `get_repo()` returns
/// `RepoNotFound("{owner}/{name}")` when a repo is absent; `authorize_read`
/// must produce the same payload for an unauthorized private read.
#[test]
fn private_denial_is_indistinguishable_from_missing() {
let err = authorize_read(&repo(false), None, "owner", "secret").unwrap_err();
match err {
AppError::RepoNotFound(msg) => assert_eq!(msg, "owner/secret"),
other => panic!("expected RepoNotFound, got {other:?}"),
}
}
}
7 changes: 4 additions & 3 deletions crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,18 +304,19 @@ pub fn build_router(state: AppState) -> Router {
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
)
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs));
);

// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
let git_read_routes = Router::new()
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs))
.route(
"/{owner}/{repo}/git-upload-pack",
post(repos::git_upload_pack),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit));
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));

// ── Meta ──────────────────────────────────────────────────────────────
let meta_routes = Router::new()
Expand Down