diff --git a/.env.example b/.env.example index b70d1117..1dbe9c1c 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,10 @@ # All variables are optional unless marked REQUIRED. # ── Node identity ───────────────────────────────────────────────────────── -# Path to the node's Ed25519 keypair PEM file. +# Path to the node's Ed25519 keypair PEM file. Must be absolute: its parent is +# also the delegation store that `gl ucan import` writes and `git-remote-gitlawb` +# reads, and the two do not share a working directory, so a relative path sends +# them to different places. `~/...` is expanded; a bare `~` is not accepted. # Generate with: gl identity new GITLAWB_KEY=/data/keys/identity.pem @@ -94,8 +97,9 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false # Require the authenticated pusher to be the repo owner on git-receive-pack. # A valid did:key signature is authentication, not authorization: anyone can -# sign as their own DID. When true, pushes from a non-owner DID are rejected. -# Keep false until the repo owner is ready for owner-only writes. +# sign as their own DID. When true, a push is accepted only from the repo owner, +# or from a holder of an owner-rooted git/push UCAN for that repo (see +# docs/RUN-A-NODE.md). Keep false until your pushers are the owner or hold one. GITLAWB_ENFORCE_OWNER_PUSH=false # Comma-separated libp2p multiaddrs. diff --git a/Cargo.lock b/Cargo.lock index b7050bc6..bd6500e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3377,10 +3377,13 @@ name = "git-remote-gitlawb" version = "0.7.1" dependencies = [ "anyhow", + "chrono", + "dirs", "gitlawb-core", "libc", "mockito", "reqwest", + "serde_json", "tempfile", "tracing", "tracing-subscriber", diff --git a/README.md b/README.md index 643992c2..6e97cd07 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Good today: Known limitations: - Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes. -- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access. +- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all. - Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer. - Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored. - Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades. @@ -264,7 +264,7 @@ metadata local disk / optional S3 | DID | A user, agent, or node identity derived from an Ed25519 public key. | | HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. | | Ref certificate | Signed record of a ref update. Useful for audit and replication. | -| UCAN | Delegation token for future capability-based workflows. | +| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | | Peer announce | Node-to-node HTTP announcement of DID + public URL. | | Gossipsub | libp2p topic for ref-update events. | | Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. | diff --git a/crates/git-remote-gitlawb/Cargo.toml b/crates/git-remote-gitlawb/Cargo.toml index b6b9e76c..3a45cee9 100644 --- a/crates/git-remote-gitlawb/Cargo.toml +++ b/crates/git-remote-gitlawb/Cargo.toml @@ -14,8 +14,17 @@ path = "src/main.rs" gitlawb-core = { path = "../gitlawb-core" } anyhow = { workspace = true } reqwest = { workspace = true } +# Reading the node's DID from `GET /` so a delegated push can address its +# invocation to the right executor. +serde_json = { workspace = true } +# The invocation carries the delegation's expiry, so an unbounded write capability +# is never minted; converting the stored i64 timestamp needs chrono. +chrono = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core, +# which takes `home` as an argument so core's dependency allowlist stays lean. +dirs = "5" [dev-dependencies] mockito = "1" diff --git a/crates/git-remote-gitlawb/src/main.rs b/crates/git-remote-gitlawb/src/main.rs index 02e39c3e..29465fd6 100644 --- a/crates/git-remote-gitlawb/src/main.rs +++ b/crates/git-remote-gitlawb/src/main.rs @@ -124,7 +124,9 @@ fn help_text() -> String { \n\ ENVIRONMENT:\n\ \x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\ - \x20 GITLAWB_KEY Identity PEM path for signed fetch/push (default: ~/.gitlawb/identity.pem)\n\ + \x20 GITLAWB_KEY Identity PEM path for signed fetch/push, absolute\n\ + \x20 (default: ~/.gitlawb/identity.pem). Its parent also\n\ + \x20 holds the delegations `gl ucan import` writes.\n\ \x20 GITLAWB_LOG Log filter (default: warn)\n\ \n\ FLAGS:\n\ @@ -358,6 +360,229 @@ fn build_advertisement_request( /// Public-repo fetch still works anonymously when no keypair is present. The body /// is signed (content-digest) but NOT attached here, so the caller can move the /// (possibly large) pack bytes into `.body()` rather than clone them. +/// How long to wait for the node's DID before giving up and pushing without a +/// delegation. Short on purpose: the answer is optional, and the node decides +/// whether the header was required. +const NODE_DID_TIMEOUT_SECS: u64 = 5; + +/// A path component safe to build a filename from. Mirrors `gl`'s +/// `ucan_cmd::is_safe_component`; change both together. +/// +/// Here the value comes from the remote URL rather than a token, and the path is +/// only read — but a `..` owner would still read an arbitrary file and send its +/// contents to the node as `X-Ucan`, so the same allow-list applies. +fn is_safe_component(s: &str) -> bool { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains("..") + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')) +} + +/// Where a delegation for `owner_did`/`repo` is stored. +/// +/// Must agree with `gl`'s `ucan_cmd::delegation_path`, which writes these files. +/// `gl` is not a dependency of this crate, so the six lines are duplicated rather +/// than shared; change both together. Keyed on the bare base58 key because +/// `did:key:` contains a colon, which Windows rejects in a filename. +fn delegation_path(dir: &std::path::Path, owner_did: &str, repo: &str) -> std::path::PathBuf { + let bare = owner_did.strip_prefix("did:key:").unwrap_or(owner_did); + dir.join("delegations").join(format!("{bare}__{repo}.ucan")) +} + +/// Wrap a stored delegation into an invocation addressed to the node. +/// +/// `iss=agent, aud=node, prf=[delegation]` is exactly the shape the node's +/// `validate_ucan_chain` expects: it binds `iss` to the request signer and `aud` +/// to its own DID, then walks `prf` to the root. +/// +/// Capabilities are copied from the delegation unchanged, so the invocation is +/// never broader than what was delegated and cannot fail attenuation. No expiry +/// is set: the delegation's own `exp` still bounds the chain, because +/// `verify_chain` checks each proof's expiry as it recurses. +fn build_invocation( + agent: &Keypair, + node_did: &gitlawb_core::did::Did, + delegation: &gitlawb_core::ucan::Ucan, + owner: &str, + repo: &str, +) -> Result { + use gitlawb_core::ucan::{caps, Capability}; + + // Narrow to the repo this push actually targets rather than copying `att` + // wholesale. A delegation written as `with: "*"` otherwise grows without + // limit — it would cover every repo the owner creates AFTER signing, a scope + // nobody chose. `is_attenuated_by` accepts a concrete resource under a `*` + // parent, so narrowing is always a legal attenuation. + // + // Constraints are copied from the covering capability, not dropped: dropping + // them is a widening and `verify_chain` refuses it. + let resource = format!("gitlawb://repos/{owner}/{repo}"); + + // The owner segment must be compared on the bare key. `parse_gitlawb_url` yields + // the bare form from the push URL, while an owner following the operator guide + // issues `--cap gitlawb://repos//` — the full DID. Comparing the + // whole resource string finds nothing, and because every failure here is + // best-effort the push goes out with no header and the delegate is told to obtain + // the delegation they already hold. + fn bare(d: &str) -> &str { + d.strip_prefix("did:key:").unwrap_or(d) + } + let names_this_repo = |with: &str| { + with.strip_prefix("gitlawb://repos/") + .and_then(|rest| rest.rsplit_once('/')) + .is_some_and(|(o, r)| bare(o) == bare(owner) && r == repo) + }; + + let source = delegation + .payload + .att + .iter() + .find(|c| { + // `constraints.is_none()` mirrors the node: `ucan_grants_push` treats any + // capability carrying `nb` as granting nothing, so selecting one here + // would mint an invocation guaranteed to be refused — and a delegation + // holding BOTH a constrained and an unconstrained grant would fail or + // succeed purely on their order in `att`. + // No `c.with == "*"` arm any more. Narrowing a wildcard to the pushed + // repo is what let one delegation reach every repo the owner has or + // later creates; the node now refuses a chain whose proof does not name + // this repository, so minting from a wildcard could only produce a push + // that fails remotely with a less obvious message. + c.constraints.is_none() + && names_this_repo(&c.with) + && (c.can == caps::GIT_PUSH || c.can == "*" || c.can == caps::REPO_ADMIN) + }) + .ok_or_else(|| { + anyhow::anyhow!("stored delegation carries no git/push capability for {resource}") + })?; + + // The parent's own resource, verbatim: the node compares `with` by equality via + // `is_attenuated_by`, and every capability reaching here already names this repo, + // so there is nothing left to narrow. + let mut narrowed = Capability::new(source.with.clone(), caps::GIT_PUSH); + narrowed.constraints = source.constraints.clone(); + + // Carry the delegation's own expiry onto the invocation. The node refuses a + // chain with any unbounded link, because without a revocation path an + // unbounded write capability can never be withdrawn. + let exp = delegation + .payload + .exp + .and_then(|e| chrono::DateTime::from_timestamp(e, 0)); + + gitlawb_core::ucan::Ucan::delegate(agent, node_did.clone(), vec![narrowed], exp, delegation) + .map_err(|e| anyhow::anyhow!("failed to build UCAN invocation: {e}")) +} + +/// Split a pack POST URL into `(node_base, owner, repo)`. +/// +/// ```text +/// https://node/zOwner/myrepo/git-receive-pack -> ("https://node", "zOwner", "myrepo") +/// https://node/gitlawb/zOwner/myrepo/git-receive-pack -> ("https://node/gitlawb", "zOwner", "myrepo") +/// ``` +/// +/// Strips the KNOWN trailing `//` rather than reading the +/// first two segments, because `GITLAWB_NODE` may carry a path prefix — a +/// reverse-proxied `https://host/gitlawb` is a supported base, and `repo_base` is +/// built as `{node_base}/{owner}/{repo}` with the service appended after it. +/// Reading from the front makes the prefix the owner, which fails the delegation +/// lookup and probes the wrong URL for the node DID. +/// +/// This is coupled to how `repo_base` is constructed in `main`; the two must +/// change together. +fn split_pack_post_url(post_url: &str) -> Option<(String, String, String)> { + let path = url_path(post_url); + let origin = post_url.strip_suffix(&path)?; + + let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + // owner, repo, service — plus any base-path prefix ahead of them. + if segs.len() < 3 { + return None; + } + let repo = segs[segs.len() - 2]; + let owner = segs[segs.len() - 3]; + let prefix = &segs[..segs.len() - 3]; + + let repo = repo.strip_suffix(".git").unwrap_or(repo); + if !is_safe_component(owner) || !is_safe_component(repo) { + return None; + } + // Every prefix segment stays part of the base the node DID is fetched from, so + // it gets the same allow-list: a `..` here would redirect that probe, and an + // encoded separator would smuggle structure past this split. + if !prefix.iter().all(|s| is_safe_component(s)) { + return None; + } + let node_base = if prefix.is_empty() { + origin.to_string() + } else { + format!("{origin}/{}", prefix.join("/")) + }; + Some((node_base, owner.to_string(), repo.to_string())) +} + +/// Build the `X-Ucan` value for a delegated push, or `None` when this push does +/// not need one. +/// +/// Entirely best-effort. A missing delegation, an unreachable node, or an +/// unreadable stored token all yield `None` and the push proceeds without the +/// header — the node decides whether one was required, and a node denial must +/// reach the user rather than being pre-empted by a local guess. +fn delegation_header( + client: &reqwest::blocking::Client, + post_url: &str, + keypair: &Keypair, +) -> Option { + let (origin, owner, repo) = split_pack_post_url(post_url)?; + + // The owner pushes on their own authority; no delegation is involved. + let bare = |d: &str| d.strip_prefix("did:key:").unwrap_or(d).to_string(); + if bare(&keypair.did().to_string()) == bare(&owner) { + return None; + } + + let dir = resolve_identity_dir()?; + let path = delegation_path(&dir, &owner, &repo); + let raw = std::fs::read_to_string(&path).ok()?; + + let delegation = match gitlawb_core::ucan::Ucan::decode(raw.trim()) { + Ok(u) => u, + Err(e) => { + tracing::warn!("stored delegation at {path:?} is unreadable: {e}"); + return None; + } + }; + + // The invocation must be addressed to the node that will execute it. + // The shared client carries a 300s timeout, which is right for a pack transfer + // and wrong for a best-effort metadata probe: a stalled node would delay every + // delegated push by five minutes before falling back to sending no header. + let node_did: gitlawb_core::did::Did = client + .get(&origin) + .timeout(std::time::Duration::from_secs(NODE_DID_TIMEOUT_SECS)) + .header("User-Agent", USER_AGENT) + .send() + .ok() + .and_then(|r| r.json::().ok()) + .and_then(|v| v.get("did")?.as_str().map(str::to_owned)) + .or_else(|| { + tracing::warn!("could not read the node DID from {origin}; pushing without X-Ucan"); + None + })? + .parse() + .ok()?; + + match build_invocation(keypair, &node_did, &delegation, &owner, &repo) { + Ok(inv) => inv.encode().ok(), + Err(e) => { + tracing::warn!("could not build the UCAN invocation: {e}"); + None + } + } +} + fn build_pack_post_request( client: &reqwest::blocking::Client, post_url: &str, @@ -376,6 +601,15 @@ fn build_pack_post_request( .header("Signature-Input", signed.signature_input) .header("Signature", signed.signature); tracing::debug!("signed {service} POST (DID: {})", kp.did()); + + // A non-owner pushing under a delegation presents it here. Only on the + // push: a fetch is gated by read visibility, not by git/push. + if service == "git-receive-pack" { + if let Some(token) = delegation_header(client, post_url, kp) { + tracing::debug!("attaching a delegated push capability"); + req = req.header("X-Ucan", token); + } + } } else if service == "git-receive-pack" { tracing::warn!("no identity keypair found, push will be unsigned (v0.1 local alpha only)"); } @@ -742,7 +976,7 @@ fn safe_error_body_excerpt(body: &str) -> String { // ── Keypair loading ─────────────────────────────────────────────────────────── fn load_keypair() -> Option { - let key_path = resolve_key_path(); + let key_path = resolve_key_path()?; if !key_path.exists() { tracing::debug!("no keypair found at {key_path:?}"); return None; @@ -765,16 +999,38 @@ fn load_keypair() -> Option { } } -fn resolve_key_path() -> std::path::PathBuf { - let path_str = - std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string()); +/// The identity PEM, resolved by the same rules `gl` uses. +/// +/// Shared through `gitlawb-core` rather than reimplemented here: this helper and +/// `gl ucan import` have to derive the same delegation store from `GITLAWB_KEY`, +/// and the two had drifted — the local version read `env::var` (so a non-UTF-8 +/// value silently became the default key), expanded only a literal `"~/"`, fell +/// back to `"."` when `HOME` was unset, and never required an absolute path. +/// +/// `None` means the value is unusable, not that the key is missing. Git runs this +/// helper mid-push, so a misconfiguration is logged and the push continues +/// unsigned rather than aborting the transfer. +fn resolve_key_path() -> Option { + gitlawb_core::identity_path::identity_key_path(home_dir().as_deref()) + .inspect_err(|e| tracing::warn!("cannot resolve the identity key path: {e}")) + .ok() +} + +/// The directory holding `identity.pem` and `delegations/`. +fn resolve_identity_dir() -> Option { + gitlawb_core::identity_path::identity_dir(home_dir().as_deref()) + .inspect_err(|e| tracing::warn!("cannot resolve the identity directory: {e}")) + .ok() +} - if let Some(stripped) = path_str.strip_prefix("~/") { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - std::path::PathBuf::from(home).join(stripped) - } else { - std::path::PathBuf::from(path_str) - } +/// `dirs`, not `$HOME`: the old code fell back to `"."` when `HOME` was unset, +/// which on Windows is always, so the default key resolved against whatever +/// directory git happened to invoke the helper from. +/// +/// `None` is not fatal — an absolute `GITLAWB_KEY` resolves without it, and only +/// the default and `~/`-prefixed forms need a home at all. +fn home_dir() -> Option { + dirs::home_dir() } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -2170,3 +2426,566 @@ mod tests { ); } } + +#[cfg(test)] +mod delegated_push_tests { + use super::*; + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + #[test] + fn invocation_wraps_the_delegation_and_targets_the_node() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)], + None, + ) + .expect("issue"); + + let invocation = + build_invocation(&agent, &node.did(), &delegation, "zowner", "r").expect("wrap"); + + assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes"); + assert_eq!(invocation.payload.aud, node.did(), "the node executes"); + assert_eq!( + invocation.payload.prf.len(), + 1, + "exactly one proof: chains are linear" + ); + assert_eq!( + invocation.verify_chain().expect("must verify"), + owner.did(), + "the chain must still root at the owner after wrapping" + ); + } + + /// An expired delegation cannot be laundered into a live one by wrapping it. + /// + /// Two independent guards now cover this: the invocation inherits the + /// delegation's `exp`, so it is expired on its own terms, AND `verify_chain` + /// recurses into the proof and rejects it there. Inheriting the expiry is what + /// keeps the node's "every link must be bounded" rule satisfiable — a leaf with + /// no expiry would be refused outright. + #[test] + fn an_expired_delegation_cannot_be_laundered_by_wrapping_it() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let expired = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)], + Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ) + .expect("issue expired"); + + let invocation = + build_invocation(&agent, &node.did(), &expired, "zowner", "r").expect("wrap"); + + assert_eq!( + invocation.payload.exp, expired.payload.exp, + "the invocation inherits the delegation's expiry, never a longer one" + ); + assert!( + !invocation.chain_lifetime_is_bounded() || invocation.is_expired(), + "an inherited expiry in the past leaves the invocation expired" + ); + let err = invocation + .verify_chain() + .expect_err("an expired proof must fail the chain"); + assert!( + err.to_string().contains("expired"), + "the failure must name expiry, got: {err}" + ); + } + + /// The shipping combination: the owner issues with the FULL DID (what + /// `RUN-A-NODE.md` instructs), while the push URL yields the BARE key + /// (`parse_gitlawb_url` takes the last colon-delimited segment). Comparing the + /// whole resource string never matches, so the delegation is not found, no + /// `X-Ucan` is sent, and the delegate gets a 403 telling them to obtain the + /// delegation they are already holding. + #[test] + fn build_invocation_matches_a_full_did_delegation_against_a_bare_owner() { + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let full = owner.did().to_string(); + let bare = full.strip_prefix("did:key:").unwrap().to_string(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{full}/r"), + caps::GIT_PUSH, + )], + Some(hour), + ) + .expect("issue"); + + let invocation = build_invocation(&agent, &node.did(), &delegation, &bare, "r") + .expect("a full-DID delegation must match a bare-owner push URL"); + + // The narrowed capability must keep the parent's exact `with`: the node's + // `is_attenuated_by` compares it by equality, so emitting the bare form under + // a full-DID parent would fail attenuation and be refused at the node. + assert_eq!( + invocation.payload.att[0].with, + format!("gitlawb://repos/{full}/r"), + "narrowing must not rewrite a resource that already names this repo" + ); + assert!( + invocation.payload.att[0].is_attenuated_by(&delegation.payload.att[0]), + "the narrowed capability must still attenuate under its parent" + ); + } + + /// A wildcard delegation is refused locally now, rather than narrowed. + /// + /// Narrowing `*` to the pushed repo is what let one delegation reach every + /// repository the owner had or later created: the node saw a concrete leaf and + /// the `*` proof behind it satisfied attenuation. The node now requires every + /// link to name the repository, so minting from a wildcard could only produce a + /// push refused remotely with a vaguer message. Failing here says why. + #[test] + fn build_invocation_refuses_a_wildcard_delegation() { + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + Some(hour), + ) + .expect("issue"); + + assert!( + build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r").is_err(), + "a bare wildcard cannot be narrowed into a usable invocation any more" + ); + } + + /// A delegation holding both a constrained and an unconstrained grant must work + /// regardless of their order. The node refuses a constrained leaf outright, so + /// picking merely the FIRST push-class capability made a valid delegation + /// succeed or fail purely on how the owner happened to order `att`. + #[test] + fn build_invocation_skips_a_constrained_capability_in_either_order() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let resource = "gitlawb://repos/z6MkOwner/r"; + + let mut constrained = Capability::new(resource, caps::GIT_PUSH); + constrained.constraints = Some(serde_json::json!({"max_bytes": 1})); + let usable = Capability::new(resource, caps::GIT_PUSH); + + for (label, att) in [ + ( + "constrained first", + vec![constrained.clone(), usable.clone()], + ), + ("usable first", vec![usable.clone(), constrained.clone()]), + ] { + let delegation = Ucan::issue(&owner, agent.did(), att, Some(hour)).expect("issue"); + let invocation = build_invocation(&agent, &node.did(), &delegation, "z6MkOwner", "r") + .unwrap_or_else(|e| panic!("{label}: {e}")); + assert!( + invocation.payload.att[0].constraints.is_none(), + "{label}: the invocation must carry the capability the node can authorize" + ); + } + + // With ONLY a constrained grant there is nothing the node would accept, so + // failing here beats minting an invocation guaranteed to be refused. + let only_constrained = + Ucan::issue(&owner, agent.did(), vec![constrained], Some(hour)).expect("issue"); + assert!( + build_invocation(&agent, &node.did(), &only_constrained, "z6MkOwner", "r").is_err(), + "a delegation with no unconstrained grant must not produce an invocation" + ); + } + + #[test] + fn split_pack_post_url_separates_origin_owner_and_repo() { + assert_eq!( + split_pack_post_url("http://127.0.0.1:7545/z6Mk/myrepo.git/git-receive-pack"), + Some(( + "http://127.0.0.1:7545".to_string(), + "z6Mk".to_string(), + "myrepo".to_string() + )), + "the origin must come back intact so the node DID can be fetched from it" + ); + // A reverse-proxied GITLAWB_NODE carries a path prefix, which survives into + // the pack URL through `repo_base`. Reading the FIRST two segments as + // owner/repo makes the prefix the owner: the delegation lookup misses, the + // DID probe hits the wrong URL, no X-Ucan is sent, and a valid delegate is + // refused with 403 — silently, because every failure here is best-effort. + assert_eq!( + split_pack_post_url("https://host/gitlawb/z6Mk/myrepo/git-receive-pack"), + Some(( + "https://host/gitlawb".to_string(), + "z6Mk".to_string(), + "myrepo".to_string() + )), + "a path-prefixed node base must keep its prefix and still find owner/repo" + ); + assert_eq!( + split_pack_post_url("https://host/a/b/c/z6Mk/myrepo/git-receive-pack"), + Some(( + "https://host/a/b/c".to_string(), + "z6Mk".to_string(), + "myrepo".to_string() + )), + "prefix depth is not fixed" + ); + // The .git suffix is optional on the wire; the delegation is stored under + // the bare repo name either way, so both forms must resolve identically. + assert_eq!( + split_pack_post_url("https://node.example/z6Mk/myrepo/git-receive-pack") + .map(|(_, _, r)| r), + Some("myrepo".to_string()) + ); + // A repo genuinely named "x.git" keeps its name: only one suffix is stripped. + assert_eq!( + split_pack_post_url("https://node.example/z6Mk/x.git.git/git-receive-pack") + .map(|(_, _, r)| r), + Some("x.git".to_string()) + ); + for bad in [ + "not-a-url", + "https://node.example", + "https://node.example/", + "https://node.example/onlyowner", + // A traversing owner would read an arbitrary file and send it to the + // node as X-Ucan, so it must not resolve to a lookup at all. + "https://node.example/../../etc/passwd/git-receive-pack", + "https://node.example/../x/git-receive-pack", + "https://node.example/a%2Fb/x/git-receive-pack", + ] { + assert!( + split_pack_post_url(bad).is_none(), + "{bad} must not parse as a pack POST URL" + ); + } + } + + #[test] + fn delegation_path_matches_the_gl_layout() { + let base = std::path::Path::new("/tmp/id"); + let expected = base.join("delegations").join("z6MkAbc__myrepo.ucan"); + assert_eq!(delegation_path(base, "did:key:z6MkAbc", "myrepo"), expected); + assert_eq!(delegation_path(base, "z6MkAbc", "myrepo"), expected); + } + + // ── delegation_header ───────────────────────────────────────────────────── + // + // Everything above tests one piece in isolation. `delegation_header` is where + // they compose — URL split, owner comparison, store lookup, node-DID probe, + // invocation build — and it is the piece with no safety net: every failure + // inside it is deliberately silent, so a regression does not fail loudly, it + // just stops attaching `X-Ucan` and the delegate starts getting 403s with no + // local explanation. + + /// `delegation_header` resolves its store from `GITLAWB_KEY`, which is + /// process-global. Every case that sets it takes this lock. + /// + /// Only these cases need it. `advertisement_and_pack_post_are_signed_…` also + /// reaches `delegation_header` (through `build_pack_post_request` on + /// `git-receive-pack`), but it asserts on signature headers alone and is + /// unaffected by whichever store is in scope. + static KEY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Point `GITLAWB_KEY` at `dir/identity.pem` for the duration of `f`, so the + /// delegation store resolves to `dir/delegations`. + fn with_identity_dir(dir: &std::path::Path, f: impl FnOnce() -> T) -> T { + let _guard = KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let restore = std::env::var_os("GITLAWB_KEY"); + std::env::set_var("GITLAWB_KEY", dir.join("identity.pem")); + let out = f(); + match restore { + Some(v) => std::env::set_var("GITLAWB_KEY", v), + None => std::env::remove_var("GITLAWB_KEY"), + } + out + } + + /// Write a token where `gl ucan import` would have left it. + fn store_delegation(dir: &std::path::Path, owner: &str, repo: &str, raw: &str) { + let path = delegation_path(dir, owner, repo); + std::fs::create_dir_all(path.parent().expect("delegations dir")).expect("mkdir"); + std::fs::write(path, raw).expect("write delegation"); + } + + fn bare(did: &gitlawb_core::did::Did) -> String { + did.to_string() + .strip_prefix("did:key:") + .expect("did:key") + .to_string() + } + + fn push_delegation(owner: &Keypair, agent: &Keypair, resource: &str) -> Ucan { + Ucan::issue( + owner, + agent.did(), + vec![Capability::new(resource, caps::GIT_PUSH)], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .expect("issue") + } + + fn did_body(node: &Keypair) -> String { + format!(r#"{{"did":"{}"}}"#, node.did()) + } + + #[test] + fn delegation_header_wraps_a_stored_delegation_and_targets_the_node() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let owner_key = bare(&owner.did()); + + let delegation = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r")); + + let mut server = mockito::Server::new(); + let did_probe = server + .mock("GET", "/") + .with_header("content-type", "application/json") + .with_body(did_body(&node)) + .create(); + + let dir = tempfile::tempdir().expect("tempdir"); + store_delegation( + dir.path(), + &owner_key, + "r", + &delegation.encode().expect("encode"), + ); + + let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url()); + let client = reqwest::blocking::Client::new(); + let header = + with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent)) + .expect("a stored delegation must produce an X-Ucan"); + + did_probe.assert(); + + let invocation = Ucan::decode(&header).expect("the header must decode as a UCAN"); + assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes"); + assert_eq!( + invocation.payload.aud, + node.did(), + "addressed to the node that will execute it" + ); + assert_eq!( + invocation.verify_chain().expect("chain must verify"), + owner.did(), + "the chain must root at the repo owner" + ); + assert!( + invocation.chain_lifetime_is_bounded(), + "the node refuses an unbounded push chain, so every link must carry an expiry" + ); + } + + /// The owner pushes on their own authority. The comparison has to survive the + /// form mismatch — the keypair holds `did:key:z…`, the URL carries the bare key + /// — or the owner takes the delegate path, finds nothing, and pays a node + /// round-trip on every push. `.expect(0)` is the assertion that matters here. + #[test] + fn delegation_header_is_skipped_when_the_pusher_is_the_owner() { + let owner = Keypair::generate(); + let node = Keypair::generate(); + let owner_key = bare(&owner.did()); + + let mut server = mockito::Server::new(); + let did_probe = server + .mock("GET", "/") + .with_body(did_body(&node)) + .expect(0) + .create(); + + // A delegation the owner does not need. Present so the assertion is about + // the owner check and not about an empty store. + let dir = tempfile::tempdir().expect("tempdir"); + let delegate = Keypair::generate(); + store_delegation( + dir.path(), + &owner_key, + "r", + &push_delegation(&owner, &delegate, &format!("gitlawb://repos/{owner_key}/r")) + .encode() + .expect("encode"), + ); + + let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url()); + let client = reqwest::blocking::Client::new(); + let header = + with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &owner)); + + assert!(header.is_none(), "the owner needs no delegation"); + did_probe.assert(); + } + + /// Best-effort means best-effort: nothing here may panic or block the push. The + /// node decides whether a delegation was required, and its denial has to reach + /// the user instead of being pre-empted by a local guess. + #[test] + fn delegation_header_is_absent_without_a_usable_stored_delegation() { + let agent = Keypair::generate(); + let node = Keypair::generate(); + let owner_key = bare(&Keypair::generate().did()); + + let mut server = mockito::Server::new(); + let _did = server.mock("GET", "/").with_body(did_body(&node)).create(); + let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url()); + let client = reqwest::blocking::Client::new(); + + // Nothing stored at all. + let empty = tempfile::tempdir().expect("tempdir"); + assert!( + with_identity_dir(empty.path(), || delegation_header( + &client, &post_url, &agent + )) + .is_none(), + "an empty store must yield no header, not an error" + ); + + // Stored, but not a UCAN — a truncated write or a hand-edited file. + let garbage = tempfile::tempdir().expect("tempdir"); + store_delegation(garbage.path(), &owner_key, "r", "not a ucan"); + assert!( + with_identity_dir(garbage.path(), || delegation_header( + &client, &post_url, &agent + )) + .is_none(), + "an unreadable stored token must yield no header, not a panic" + ); + + // Stored and valid, but for a capability the push path cannot use. `gl ucan + // import` refuses these now; a store written by an older `gl` still holds them. + let owner = Keypair::generate(); + let wrong_owner_key = bare(&owner.did()); + let unusable = tempfile::tempdir().expect("tempdir"); + let fetch_only = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{wrong_owner_key}/r"), + caps::GIT_FETCH, + )], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .expect("issue"); + store_delegation( + unusable.path(), + &wrong_owner_key, + "r", + &fetch_only.encode().expect("encode"), + ); + let fetch_url = format!("{}/{wrong_owner_key}/r/git-receive-pack", server.url()); + assert!( + with_identity_dir(unusable.path(), || delegation_header( + &client, &fetch_url, &agent + )) + .is_none(), + "a git/fetch delegation carries no push capability to wrap" + ); + } + + /// The node DID addresses the invocation, so without it there is nothing to + /// build. A node that is down, slow, or serving something other than JSON must + /// cost the push a header, never an abort. + #[test] + fn delegation_header_is_absent_when_the_node_did_cannot_be_read() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let owner_key = bare(&owner.did()); + let encoded = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r")) + .encode() + .expect("encode"); + let client = reqwest::blocking::Client::new(); + + for (label, status, body) in [ + ("a 500 from the node", 500, "boom"), + ("a JSON body with no did", 200, r#"{"name":"gitlawb"}"#), + ("an HTML error page from a proxy", 200, "502"), + ("a did that does not parse", 200, r#"{"did":"not-a-did"}"#), + ] { + let mut server = mockito::Server::new(); + let _did = server + .mock("GET", "/") + .with_status(status) + .with_body(body) + .create(); + + let dir = tempfile::tempdir().expect("tempdir"); + store_delegation(dir.path(), &owner_key, "r", &encoded); + let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url()); + + assert!( + with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent)) + .is_none(), + "{label} must drop the header, not fail the push" + ); + } + } + + /// A reverse-proxied `GITLAWB_NODE` carries a path prefix, and that prefix + /// survives into the pack URL. `split_pack_post_url` is unit-tested for it, but + /// nothing checked that the probe actually goes to the prefixed base — a + /// regression there would GET `/` on the proxy host, read whatever landing page + /// it serves, and silently drop the header. `.expect(0)` on `/` is the half that + /// catches it. + #[test] + fn delegation_header_probes_the_prefixed_node_base() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let owner_key = bare(&owner.did()); + + let mut server = mockito::Server::new(); + let prefixed = server + .mock("GET", "/gitlawb") + .with_body(did_body(&node)) + .create(); + let root = server.mock("GET", "/").expect(0).create(); + + let dir = tempfile::tempdir().expect("tempdir"); + store_delegation( + dir.path(), + &owner_key, + "r", + &push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r")) + .encode() + .expect("encode"), + ); + + let post_url = format!("{}/gitlawb/{owner_key}/r/git-receive-pack", server.url()); + let client = reqwest::blocking::Client::new(); + let header = + with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent)) + .expect("a path-prefixed node base must still yield an X-Ucan"); + + prefixed.assert(); + root.assert(); + assert_eq!( + Ucan::decode(&header).expect("decode").payload.aud, + node.did() + ); + } +} diff --git a/crates/gitlawb-core/src/identity_path.rs b/crates/gitlawb-core/src/identity_path.rs new file mode 100644 index 00000000..64026798 --- /dev/null +++ b/crates/gitlawb-core/src/identity_path.rs @@ -0,0 +1,299 @@ +//! Where the identity key and the delegation store live. +//! +//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a +//! delegation to `/delegations/`, and the helper reads it back from the same +//! place when it builds the `X-Ucan` header on push. If the two resolve +//! `GITLAWB_KEY` differently the push goes out with no header and the node refuses +//! the delegate, with nothing on either side to say why — so the rules live here +//! once, in the crate both binaries already depend on, rather than being written +//! twice and drifting. +//! +//! The home directory is a parameter rather than something this module looks up. +//! `gitlawb-core` is embedded by every consumer and is held to an explicit +//! dependency allowlist (`ci/gitlawb-core-allowed-deps.txt`); the callers already +//! carry `dirs`, so taking `home` keeps the rules shared without widening core's +//! tree. It also makes every case below testable against a fixed home. + +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; + +use crate::{Error, Result}; + +/// Environment variable naming the identity PEM. +pub const KEY_ENV: &str = "GITLAWB_KEY"; + +/// Name of the PEM file inside the identity directory. +pub const KEY_FILE_NAME: &str = "identity.pem"; + +/// Directory under the home directory used when `GITLAWB_KEY` is unset. +pub const DEFAULT_DIR_NAME: &str = ".gitlawb"; + +/// Absolute path of the identity PEM: `$GITLAWB_KEY`, else +/// `/.gitlawb/identity.pem`. +/// +/// An empty `GITLAWB_KEY` counts as unset. That is what a shell leaves behind for +/// `FOO=` and for an unset variable expanded into a wrapper script, and reading it +/// as a path would resolve against the process working directory instead. +pub fn identity_key_path(home: Option<&Path>) -> Result { + // `var_os`, not `var`: `var` folds a non-UTF-8 value into the same `Err` as + // unset, so an operator whose key path is not valid UTF-8 would silently get + // the default directory rather than theirs — or an error naming the real + // problem. + match std::env::var_os(KEY_ENV) { + Some(raw) if !raw.is_empty() => resolve_key_value(Path::new(&raw), home), + _ => Ok(require_home(home)? + .join(DEFAULT_DIR_NAME) + .join(KEY_FILE_NAME)), + } +} + +/// The directory holding `identity.pem` and `delegations/` — the parent of +/// [`identity_key_path`]. +pub fn identity_dir(home: Option<&Path>) -> Result { + let key = identity_key_path(home)?; + key.parent().map(Path::to_path_buf).ok_or_else(|| { + Error::Key(format!( + "{KEY_ENV} has no parent directory: {}", + key.display() + )) + }) +} + +/// The home directory, demanded only where the value being resolved needs it. +/// +/// An absolute `GITLAWB_KEY` never needs home, so requiring it up front would +/// discard a perfectly good key on a host where `dirs::home_dir()` returns `None` +/// — no `HOME` and no passwd entry, which is an ordinary container shape. The +/// helper would then push unsigned, blaming the home directory for a setting the +/// operator had configured correctly. +fn require_home(home: Option<&Path>) -> Result<&Path> { + home.ok_or_else(|| { + Error::Key(format!( + "could not determine the home directory, which is needed to resolve this \ + {KEY_ENV} value. Set {KEY_ENV} to an absolute path to avoid needing it." + )) + }) +} + +/// Apply the `GITLAWB_KEY` rules to a raw value. +/// +/// Split out from [`identity_key_path`] so the rules can be tested without setting +/// a process-global environment variable, which would make the tests race. +fn resolve_key_value(raw: &Path, home: Option<&Path>) -> Result { + let path = expand_tilde(raw, home)?; + + if !path.is_absolute() { + return Err(Error::Key(format!( + "{KEY_ENV} must be an absolute path (got {}). It also determines where \ + delegations are stored, and `gl` and `git-remote-gitlawb` do not share a \ + working directory, so a relative path sends them to different stores.", + raw.display() + ))); + } + if path.parent().is_none() { + return Err(Error::Key(format!( + "{KEY_ENV} must name the key file, not the filesystem root (got {}). \ + Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.", + raw.display() + ))); + } + Ok(path) +} + +/// Expand a leading `~/`, and only that. +/// +/// `~user` is shell syntax this does not implement; leaving its `~` in place makes +/// it fail the absolute-path check with a message that names the real problem, +/// which beats resolving it somewhere the operator did not ask for. A bare `~` or +/// `~/` is refused outright: it names a directory where a file is required, and +/// expanding it to the home directory would put the delegation store beside the +/// home directory rather than inside it, since the store is the key's *parent*. +/// +/// Matched on the first path component rather than on a string prefix. That is +/// what lets the value stay an `OsStr` end to end: the helper's old +/// `str::strip_prefix("~/")` needed a `String` first, which is why it reached for +/// `env::var` and folded every non-UTF-8 path into "unset". +fn expand_tilde(path: &Path, home: Option<&Path>) -> Result { + let mut components = path.components(); + match components.next() { + Some(Component::Normal(first)) if first == OsStr::new("~") => { + let rest = components.as_path(); + if rest.as_os_str().is_empty() { + return Err(Error::Key(format!( + "{KEY_ENV} must name the key file, not a directory (got {}). \ + Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.", + path.display() + ))); + } + Ok(require_home(home)?.join(rest)) + } + _ => Ok(path.to_path_buf()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A home that is absolute on the host running the tests. `/home/op` is not + /// absolute on Windows — it has a root but no prefix — so a shared literal + /// would make the absolute-path assertions test the wrong thing there. + fn home() -> PathBuf { + if cfg!(windows) { + PathBuf::from(r"C:\Users\op") + } else { + PathBuf::from("/home/op") + } + } + + #[test] + fn absolute_value_is_taken_verbatim() { + let raw = home().join("data").join("keys").join(KEY_FILE_NAME); + assert_eq!(resolve_key_value(&raw, Some(&home())).unwrap(), raw); + } + + #[test] + fn tilde_slash_expands_to_the_home_directory() { + let resolved = resolve_key_value(Path::new("~/keys/identity.pem"), Some(&home())).unwrap(); + assert_eq!(resolved, home().join("keys").join(KEY_FILE_NAME)); + } + + /// The shell-style spelling of the default resolves to the default. Worth + /// pinning: this is the form an operator gets by copying a path out of their + /// shell, and the two binaries used to reach it by different routes. + #[test] + fn the_tilde_spelling_of_the_default_resolves_to_the_default() { + let resolved = + resolve_key_value(Path::new("~/.gitlawb/identity.pem"), Some(&home())).unwrap(); + assert_eq!(resolved, home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME)); + } + + /// A relative value resolves against the working directory, and `gl` and + /// `git-remote-gitlawb` do not share one: the import would land where the helper + /// never looks. + #[test] + fn relative_values_are_refused() { + for raw in ["identity.pem", "keys/identity.pem", "./keys/identity.pem"] { + assert!( + resolve_key_value(Path::new(raw), Some(&home())).is_err(), + "{raw} is relative and must be refused" + ); + } + } + + /// `~user` is shell syntax, not a path, and a bare `~` names a directory where + /// a file is required. Refused rather than guessed at. + #[test] + fn unsupported_tilde_forms_are_refused() { + for raw in ["~", "~/", "~someone/keys/identity.pem"] { + assert!( + resolve_key_value(Path::new(raw), Some(&home())).is_err(), + "{raw} must be refused rather than resolved" + ); + } + } + + /// The root has no parent, so the delegation store would have nowhere to go. + #[test] + fn the_filesystem_root_is_refused() { + assert!(resolve_key_value(Path::new("/"), Some(&home())).is_err()); + } + + /// The whole point of `var_os`: a non-UTF-8 value must reach the rules rather + /// than being folded into "unset" by `var`. Byte 0xFF is not valid UTF-8 in any + /// position, so this value is unreachable through `env::var`. + #[cfg(unix)] + #[test] + fn non_utf8_values_reach_the_rules() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let relative = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec()); + assert!( + resolve_key_value(Path::new(&relative), Some(&home())).is_err(), + "a non-UTF-8 relative path must be refused, not silently defaulted" + ); + + let mut absolute = OsString::from("/data/"); + absolute.push(OsString::from_vec(vec![0xFF])); + absolute.push("/identity.pem"); + let resolved = resolve_key_value(Path::new(&absolute), Some(&home())).unwrap(); + assert_eq!(resolved.as_os_str(), absolute.as_os_str()); + } + + /// Unset and empty both mean "use the default", and the two accessors must stay + /// consistent: the directory is the parent of the key, never a sibling of it. + /// The process environment is global, so the two cases share one test and one + /// lock rather than racing each other. + #[test] + fn unset_and_empty_both_select_the_default_directory() { + use std::sync::Mutex; + static LOCK: Mutex<()> = Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let restore = std::env::var_os(KEY_ENV); + + std::env::remove_var(KEY_ENV); + let unset = ( + identity_key_path(Some(&home())), + identity_dir(Some(&home())), + ); + std::env::set_var(KEY_ENV, ""); + let empty = ( + identity_key_path(Some(&home())), + identity_dir(Some(&home())), + ); + + match restore { + Some(v) => std::env::set_var(KEY_ENV, v), + None => std::env::remove_var(KEY_ENV), + } + + for (label, (key, dir)) in [("unset", unset), ("empty", empty)] { + assert_eq!( + key.unwrap(), + home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME), + "{label} key path" + ); + assert_eq!( + dir.unwrap(), + home().join(DEFAULT_DIR_NAME), + "{label} directory" + ); + } + } +} + +#[cfg(test)] +mod no_home_tests { + use super::*; + + /// An absolute key needs no home directory. Demanding one up front discarded a + /// correctly-configured `GITLAWB_KEY` on any host where `dirs::home_dir()` + /// returns `None` — no `HOME` and no passwd entry, an ordinary container shape — + /// and the helper then pushed unsigned while blaming the home directory. + #[test] + fn an_absolute_key_resolves_without_a_home_directory() { + let raw = if cfg!(windows) { + r"C:\data\keys\identity.pem" + } else { + "/data/keys/identity.pem" + }; + let resolved = resolve_key_value(Path::new(raw), None) + .expect("an absolute key must not need a home directory"); + assert_eq!(resolved, PathBuf::from(raw)); + assert_eq!(resolved.parent().unwrap(), Path::new(raw).parent().unwrap()); + } + + /// The forms that genuinely need a home still say so, rather than resolving + /// somewhere arbitrary. + #[test] + fn the_forms_that_need_a_home_report_its_absence() { + let err = resolve_key_value(Path::new("~/keys/identity.pem"), None) + .expect_err("a ~/ path cannot resolve without a home directory"); + assert!( + err.to_string().contains("home directory"), + "the error must name the missing home directory, got: {err}" + ); + } +} diff --git a/crates/gitlawb-core/src/lib.rs b/crates/gitlawb-core/src/lib.rs index efa99897..2f35418f 100644 --- a/crates/gitlawb-core/src/lib.rs +++ b/crates/gitlawb-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod encrypt; pub mod error; pub mod http_sig; pub mod identity; +pub mod identity_path; pub mod sanitize; pub mod ucan; diff --git a/crates/gitlawb-core/src/ucan.rs b/crates/gitlawb-core/src/ucan.rs index 86b3dd9f..95f54425 100644 --- a/crates/gitlawb-core/src/ucan.rs +++ b/crates/gitlawb-core/src/ucan.rs @@ -49,11 +49,32 @@ impl Capability { /// action field and `repo/admin` in the parent's action position act as /// wildcards that cover any delegated value; wildcards on `self` carry no /// special meaning. + /// + /// Constraints (`nb`) participate, and conservatively: + /// + /// | parent | child | verdict | + /// |---|---|---| + /// | none | anything | attenuated — adding constraints narrows | + /// | some | identical | attenuated | + /// | some | different | refused — narrowing is unprovable without semantics | + /// | some | none | refused — dropping constraints widens | + /// + /// The last row is the one that matters. Ignoring `nb` here let a holder of a + /// constrained capability re-delegate the same resource and action with the + /// constraints removed, and the chain still verified — so a consumer that + /// refuses constrained capabilities at the leaf saw an unconstrained one and + /// granted it. Since `nb` has no interpreted semantics yet, "different" cannot + /// be shown to be narrower and is refused with it. pub fn is_attenuated_by(&self, parent: &Capability) -> bool { let resource_ok = parent.with == self.with || parent.with == "*"; let action_ok = parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN; - resource_ok && action_ok + let constraints_ok = match (&parent.constraints, &self.constraints) { + (None, _) => true, + (Some(p), Some(c)) => p == c, + (Some(_), None) => false, + }; + resource_ok && action_ok && constraints_ok } } @@ -146,6 +167,29 @@ impl Ucan { } } + /// Whether every link in this chain carries a finite `exp`. + /// + /// `exp` is optional in the format, and [`Self::is_expired`] reports `false` + /// when it is absent — so a link without one never expires. With no revocation + /// mechanism, a chain containing such a link is a permanent grant: a leaked + /// token cannot be withdrawn, and the issuer's only remedy is to rotate the + /// identity the resource is keyed on. + /// + /// Consumers that turn a UCAN into write authority should require this. It is + /// deliberately not enforced inside [`Self::verify_chain`], because a + /// non-expiring token is well-formed and may be perfectly appropriate for a + /// read-only or advisory capability; whether an unbounded grant is acceptable + /// is the consumer's policy, not the format's. + pub fn chain_lifetime_is_bounded(&self) -> bool { + if self.payload.exp.is_none() { + return false; + } + self.payload + .prf + .iter() + .all(|token| Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded())) + } + /// Check if this UCAN's not-before time is in the future (token not yet valid). pub fn is_before_valid(&self) -> bool { if let Some(nbf) = self.payload.nbf { @@ -248,8 +292,16 @@ impl Ucan { /// 3. Check the proof is not expired /// 4. Recursively verify the proof's own chain /// - /// A UCAN with no proofs (root capability) passes trivially. - pub fn verify_chain(&self) -> Result<()> { + /// A UCAN with no proofs is its own root, so it returns its own issuer. + /// + /// **This establishes internal consistency, not trust.** `did:key` is + /// self-certifying, so anyone can mint a keypair and produce a chain that + /// verifies. A caller making an authorization decision MUST compare the + /// returned root against an identity it trusts for some reason outside this + /// token — a repo owner, a configured value, a registry lookup. Discarding + /// the return value is only correct when the caller is checking that a token + /// is well-formed and deliberately does not care who issued it. + pub fn verify_chain(&self) -> Result { // First verify our own signature self.verify_signature()?; @@ -261,34 +313,44 @@ impl Ucan { return Err(Error::Ucan("token is not yet valid".to_string())); } - for proof_token in &self.payload.prf { - let proof = Self::decode(proof_token) - .map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?; + if self.payload.prf.len() > 1 { + return Err(Error::Ucan( + "multi-proof chains are not supported: more than one proof means \ + more than one root, and which root authorized a given capability \ + is ambiguous" + .to_string(), + )); + } + + let Some(proof_token) = self.payload.prf.first() else { + // No proofs: this token is its own root. + return Ok(self.payload.iss.clone()); + }; - // The proof's audience must be this UCAN's issuer - if proof.payload.aud != self.payload.iss { + let proof = Self::decode(proof_token) + .map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?; + + // The proof's audience must be this UCAN's issuer + if proof.payload.aud != self.payload.iss { + return Err(Error::Ucan(format!( + "proof chain broken: proof audience {} does not match issuer {}", + proof.payload.aud, self.payload.iss + ))); + } + + // Every delegated capability must be covered by the proof (attenuation). + for cap in &self.payload.att { + let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p)); + if !covered { return Err(Error::Ucan(format!( - "proof chain broken: proof audience {} does not match issuer {}", - proof.payload.aud, self.payload.iss + "capability attenuation violated: '{}' on '{}' not covered by proof", + cap.can, cap.with ))); } - - // Every delegated capability must be covered by the proof (attenuation). - for cap in &self.payload.att { - let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p)); - if !covered { - return Err(Error::Ucan(format!( - "capability attenuation violated: '{}' on '{}' not covered by proof", - cap.can, cap.with - ))); - } - } - - // Verify the proof's signature and chain recursively - proof.verify_chain()?; } - Ok(()) + // Recurse; the root of the proof's chain is the root of ours. + proof.verify_chain() } } @@ -664,4 +726,229 @@ mod tests { delegated.verify_chain().unwrap(); } + + #[test] + fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() { + // owner -> agent (delegation), agent -> node (invocation). + // The root is the owner: that is the identity the whole chain rests on, + // and the only one a caller can meaningfully anchor a trust decision to. + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)]; + + let delegation = + Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation"); + let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation) + .expect("wrap invocation"); + + assert_eq!( + invocation.verify_chain().expect("chain must verify"), + owner.did(), + "the root issuer is the owner who started the chain, not the agent presenting it" + ); + } + + /// A chain is only bounded if EVERY link is. An unbounded link anywhere makes + /// the whole grant permanent, because `is_expired` reports false for it and + /// there is no revocation path to withdraw it. + #[test] + fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)]; + let hour = Utc::now() + chrono::Duration::hours(1); + + let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue"); + let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue"); + + assert!( + Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root) + .expect("wrap") + .chain_lifetime_is_bounded(), + "both links finite" + ); + assert!( + !Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root) + .expect("wrap") + .chain_lifetime_is_bounded(), + "the leaf has no expiry, so the grant never lapses" + ); + assert!( + !Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root) + .expect("wrap") + .chain_lifetime_is_bounded(), + "a bounded leaf cannot rescue an unbounded proof: the holder can always \ + mint a fresh leaf from it" + ); + assert!( + !unbounded_root.chain_lifetime_is_bounded(), + "a self-issued token with no expiry is itself unbounded" + ); + } + + /// A three-link chain: owner -> lead -> agent, which is the real shape of an + /// org delegating to a team lead who delegates to a CI identity. + /// + /// Every other chain here is depth two, where the immediate proof IS the root — + /// so nothing distinguishes recursing to the true root from simply returning the + /// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing: + /// without the second, returning the middle issuer would still satisfy a test + /// that only checked "not the leaf". + #[test] + fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() { + let owner = Keypair::generate(); + let lead = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)]; + + let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead"); + let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent"); + let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node"); + + let found = leaf.verify_chain().expect("a three-link chain must verify"); + assert_eq!( + found, + owner.did(), + "the root is the owner who started the chain, two hops up" + ); + assert_ne!( + found, + lead.did(), + "returning the immediate proof's issuer is not walking to the root" + ); + } + + #[test] + fn verify_chain_returns_self_as_root_for_a_self_issued_token() { + // A token with no proofs roots at its own issuer. This is what makes a + // self-minted token useless: the caller compares this against the repo + // owner and it will only ever match when the presenter IS the owner. + let agent = Keypair::generate(); + let node = Keypair::generate(); + let ucan = + Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue"); + + assert_eq!( + ucan.verify_chain().expect("a root token still verifies"), + agent.did(), + "a self-minted token roots at the minter, however permissive its capabilities" + ); + } + + /// Stripping `nb` is a widening, and a widening must fail attenuation. + /// + /// Without this, a constrained delegation is trivially escalated: the holder + /// re-delegates the same resource and action with the constraints removed, + /// `verify_chain` accepts the chain because attenuation only compared `with` + /// and `can`, and a consumer that refuses constrained capabilities at the leaf + /// (as the node's push gate does) then sees an unconstrained one and grants it. + /// Guarding only the leaf guards the wrong end of the chain. + #[test] + fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + + let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH) + .with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] })); + let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None) + .expect("issue constrained delegation"); + + // Same resource, same action, constraints dropped. + let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH); + let forged = + Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap"); + + let err = forged + .verify_chain() + .expect_err("dropping the parent's constraints must fail attenuation"); + assert!( + err.to_string().contains("attenuation"), + "the failure must name attenuation, got: {err}" + ); + } + + #[test] + fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + + let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] }); + let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH) + .with_constraints(nb.clone()); + let delegation = + Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue"); + let invocation = + Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap"); + + assert_eq!( + invocation + .verify_chain() + .expect("an unchanged constraint must verify"), + owner.did() + ); + } + + #[test] + fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() { + // Adding `nb` narrows, which is always a legal attenuation. + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + + let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH); + let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue"); + let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH) + .with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] })); + let invocation = + Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap"); + + assert_eq!( + invocation.verify_chain().expect("narrowing must verify"), + owner.did() + ); + } + + #[test] + fn verify_chain_rejects_a_multi_proof_chain() { + // Two proofs mean two roots, and nothing says which root authorized a + // given capability. Returning either one would be unsound, so refuse. + let owner_a = Keypair::generate(); + let owner_b = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)]; + + let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a"); + let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b"); + + // `delegate` only ever writes one proof, so build the two-proof payload by hand. + let payload = UcanPayload { + ucan: "1.0.0".to_string(), + iss: agent.did(), + aud: node.did(), + att: caps_vec, + exp: None, + nbf: None, + prf: vec![ + proof_a.encode().expect("encode a"), + proof_b.encode().expect("encode b"), + ], + }; + let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload"); + let s = agent.sign_b64(&signing_bytes); + let multi = Ucan { payload, s }; + + let err = multi + .verify_chain() + .expect_err("a two-proof chain must be refused"); + assert!( + err.to_string().contains("multi-proof"), + "the error must name the reason, got: {err}" + ); + } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b09cb6da..38fda741 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1596,14 +1596,21 @@ fn owner_push_rejection( enforce: bool, record: &crate::db::RepoRecord, caller: Option<&str>, + verified: Option<&crate::auth::VerifiedUcan>, ) -> Option { if !enforce { return None; } match caller { - Some(did) if caller_authorized_to_push(record, did) => None, + Some(did) if caller_authorized_to_push(record, did, verified) => None, + // One message for every refusal. It must not say whether a delegation was + // presented, was expired, or named another repository: varying it would turn + // the denial into an oracle for which capabilities exist. It only has to be + // TRUE in all of those cases, which the previous owner-only wording no + // longer was once a delegation could authorize a push. _ => Some(AppError::Forbidden( - "push rejected — only the repo owner may push to this repository \ + "push rejected — you must be the repo owner, or hold a valid \ + owner-issued git/push delegation for this repository \ (GITLAWB_ENFORCE_OWNER_PUSH is enabled)" .into(), )), @@ -1743,6 +1750,10 @@ pub async fn git_receive_pack( State(state): State, Path((owner, repo)): Path<(String, String)>, Extension(auth): Extension, + // `X-Ucan` is optional, so the extension may be absent: axum extracts that as + // `None` rather than rejecting the request. Present only when the middleware + // validated a chain. + verified: Option>, crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr, headers: axum::http::HeaderMap, body: Bytes, @@ -1790,6 +1801,7 @@ pub async fn git_receive_pack( state.config.enforce_owner_push, &record, Some(auth.0.as_str()), + verified.as_ref().map(|Extension(v)| v), ) { tracing::warn!( repo = %name, @@ -1801,9 +1813,17 @@ pub async fn git_receive_pack( } // ── Branch protection check ────────────────────────────────────────── - // Uses the same verified identity as the owner-push gate above. (When that - // gate is enabled a non-owner never reaches here; this still applies when it - // is off, gating only the branches an owner has explicitly protected.) + // Uses the same verified identity as the owner-push gate above, but a STRICTER + // predicate: owner-only, deliberately not `caller_authorized_to_push`. + // + // A delegate can therefore clear the gate above and still be refused here. That + // is the intended policy, not an oversight: a protected branch is the owner's + // explicit marker that even routine writes should stop, so a `git/push` + // delegation must not silently override it. Widening this to accept a + // delegation would make every existing protection weaker the moment the owner + // issues any capability. + // + // `delegated_push_is_still_refused_on_a_protected_branch` pins this. for update in &ref_updates { // Strip refs/heads/ prefix to get plain branch name let branch = update @@ -3326,7 +3346,7 @@ mod tests { #[test] fn enforced_allows_owner_full_did() { let repo = repo_owned_by(OWNER_DID); - assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none()); + assert!(owner_push_rejection(true, &repo, Some(OWNER_DID), None).is_none()); } #[test] @@ -3334,36 +3354,92 @@ mod tests { // Owners are accepted in bare-multibase form, matching the rest of the // codebase's owner comparisons. let repo = repo_owned_by(OWNER_DID); - assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).is_none()); + assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT), None).is_none()); } #[test] fn enforced_rejects_non_owner_with_forbidden() { let repo = repo_owned_by(OWNER_DID); - assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID))); + assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None)); } #[test] fn enforced_rejects_missing_did_with_forbidden() { // Fail closed: an absent authenticated identity is rejected, not allowed. let repo = repo_owned_by(OWNER_DID); - assert_forbidden(owner_push_rejection(true, &repo, None)); + assert_forbidden(owner_push_rejection(true, &repo, None, None)); } #[test] fn disabled_allows_non_owner_and_missing_did() { // Flag off → legacy behavior: authentication-only, no owner gate. let repo = repo_owned_by(OWNER_DID); - assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none()); - assert!(owner_push_rejection(false, &repo, None).is_none()); + assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID), None).is_none()); + assert!(owner_push_rejection(false, &repo, None, None).is_none()); + } + + /// Build a VerifiedUcan whose chain roots at `root_did` and which carries + /// `git/push` for `repo`. The token's own issuer and audience do not matter + /// here: the middleware has already bound them before this gate is reached. + fn push_delegation(root_did: &str, repo: &crate::db::RepoRecord) -> crate::auth::VerifiedUcan { + let agent = gitlawb_core::identity::Keypair::generate(); + let node = gitlawb_core::identity::Keypair::generate(); + let ucan = gitlawb_core::ucan::Ucan::issue( + &agent, + node.did(), + vec![gitlawb_core::ucan::Capability::new( + format!("gitlawb://repos/{}/{}", repo.owner_did, repo.name), + gitlawb_core::ucan::caps::GIT_PUSH, + )], + // Finite: a write capability that never lapses is refused, since there + // is no revocation path to withdraw a leaked one. + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .expect("issue delegation"); + crate::auth::VerifiedUcan { + ucan, + root: root_did.parse().expect("root DID must parse"), + } + } + + #[test] + fn enforced_allows_a_non_owner_holding_an_owner_rooted_push_capability() { + // The regression owner-only push introduced: a CI or delegated key with a + // valid capability was refused exactly like a stranger. + let repo = repo_owned_by(OWNER_DID); + let verified = push_delegation(OWNER_DID, &repo); + assert!( + owner_push_rejection(true, &repo, Some(STRANGER_DID), Some(&verified)).is_none(), + "a delegation rooted at the owner must let a non-owner push" + ); + } + + #[test] + fn enforced_rejects_a_delegation_rooted_at_a_stranger() { + // Anchoring is the whole point: a chain nobody the repo trusts started + // grants nothing, even carrying a perfectly formed push capability. + let repo = repo_owned_by(OWNER_DID); + let verified = push_delegation(STRANGER_DID, &repo); + assert_forbidden(owner_push_rejection( + true, + &repo, + Some(STRANGER_DID), + Some(&verified), + )); + } + + #[test] + fn enforced_still_rejects_a_non_owner_with_no_capability() { + let repo = repo_owned_by(OWNER_DID); + assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None)); } #[test] fn caller_authorized_to_push_is_owner_only_in_phase_1() { let repo = repo_owned_by(OWNER_DID); - assert!(caller_authorized_to_push(&repo, OWNER_DID)); - assert!(caller_authorized_to_push(&repo, OWNER_SHORT)); - assert!(!caller_authorized_to_push(&repo, STRANGER_DID)); + assert!(caller_authorized_to_push(&repo, OWNER_DID, None)); + assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None)); + assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None)); } // ── fork_withheld_blocks (#98 path-scoped fork gate) ── @@ -4993,6 +5069,13 @@ mod tests { /// task the global slot stays occupied until the walk finishes; on the pre-fix code /// the handler-local permits drop on future-drop and the slot frees instantly (RED), /// letting disconnect-spam exceed the cap while real git work keeps running. + /// + /// Unix-only: the fake git is a `/bin/sh` script made executable through + /// `PermissionsExt::set_mode`, and the hung `rev-list` is reaped with + /// `libc::kill(SIGKILL)`. Neither exists on Windows, so without this gate the + /// whole `gitlawb-node` test target fails to compile there (#228) and no test + /// in the crate can run on a Windows checkout. + #[cfg(unix)] #[sqlx::test] async fn upload_pack_permit_held_through_walk_after_disconnect(pool: sqlx::PgPool) { use axum::body::Body; @@ -5632,6 +5715,7 @@ mod tests { State(state.clone()), Path(("z6rp4wr".to_string(), "rp4".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(capped)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5650,6 +5734,7 @@ mod tests { State(state.clone()), Path(("z6rp4wr".to_string(), "rp4".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(other)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5753,6 +5838,7 @@ mod tests { State(state_for_task), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5840,6 +5926,7 @@ mod tests { State(state.clone()), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5860,6 +5947,11 @@ mod tests { /// sheds — releasing the permit lets the SAME walk run and pin (durability stays /// fail-closed). Exercises the gating seam directly; the detached push task calls /// this exact helper. + /// + /// Unix-only for the same reason as + /// `upload_pack_permit_held_through_walk_after_disconnect`: the fake git is a + /// `/bin/sh` script made executable through `PermissionsExt::set_mode` (#228). + #[cfg(unix)] #[tokio::test] async fn encrypt_walk_defers_when_pool_exhausted() { use std::sync::Arc; @@ -6369,6 +6461,7 @@ mod tests { State(state), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body(new_sha), @@ -6463,6 +6556,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF4FastPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -6523,6 +6617,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF4ParkPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), ref_update_body("2222222222222222222222222222222222222222"), @@ -7748,6 +7843,7 @@ mod tests { State(state.clone()), Path(("z6f3repo".to_string(), "r1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7776,6 +7872,7 @@ mod tests { State(state_b), Path(("z6f3repo".to_string(), "r1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some( "203.0.113.82:5000".parse::().unwrap(), )), @@ -7872,6 +7969,7 @@ mod tests { State(st), Path(("z6f3clean".to_string(), "c1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7965,6 +8063,7 @@ mod tests { State(state.clone()), Path(("z6f3dos".to_string(), "d1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7992,6 +8091,7 @@ mod tests { State(state_b), Path(("z6f3dos".to_string(), "d1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some( "203.0.113.72:5000".parse::().unwrap(), )), @@ -8176,6 +8276,7 @@ mod tests { State(st), Path(("z6u2key".to_string(), "k1".to_string())), Extension(crate::auth::AuthenticatedDid(did)), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8249,6 +8350,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkOverflowPusherAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body("1111111111111111111111111111111111111111"), @@ -8303,6 +8405,7 @@ mod tests { State(st), Path(("z6u1cap".to_string(), "c1".to_string())), Extension(crate::auth::AuthenticatedDid(did)), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8408,6 +8511,7 @@ mod tests { State(st), Path(("z6u1two".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(did)), + None, crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8489,6 +8593,7 @@ mod tests { State(st), Path(("z6u1nat".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(pusher.to_string())), + None, crate::rate_limit::PeerAddr(Some(edge)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8587,6 +8692,7 @@ mod tests { State(st), Path(("z6f1key".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8644,6 +8750,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF1NoKeyPusherAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(None), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8683,6 +8790,7 @@ mod tests { State(state.clone()), Path(("z6f1seq".to_string(), "s1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + None, crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -9191,6 +9299,7 @@ mod tests { State(state.clone()), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(P2_PUSHER.to_string())), + None, crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index eee8fd8b..1d1d7cfb 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,16 +17,155 @@ use crate::state::AppState; #[derive(Clone, Debug)] pub struct AuthenticatedDid(pub String); +/// A UCAN that passed full chain validation, with the root issuer the chain +/// rests on. Inserted into request extensions by [`require_ucan_chain`] when +/// `X-Ucan` is present; absent when the header is. +/// +/// `root` is carried rather than recomputed so the chain is walked once per +/// request. Holding this is not itself an authorization decision — a caller must +/// still compare `root` against an identity it independently trusts, because +/// `did:key` is self-certifying and anyone can mint a chain that verifies. +#[derive(Clone, Debug)] +pub struct VerifiedUcan { + pub ucan: Ucan, + pub root: Did, +} + /// Whether `caller` is authorized to push to `record`. /// -/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical -/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is -/// intentionally a distinct, intent-named gate rather than a bare owner check so -/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a -/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting -/// call sites. -pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool { +/// The repo owner, or a caller presenting a verified UCAN whose chain roots at +/// that owner and which carries `git/push` for this repo. +/// +/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches +/// the same owner-only decision it always did. The owner check is unconditional +/// and runs first, so this can only ever turn a refusal into an acceptance, +/// never the reverse. +pub fn caller_authorized_to_push( + record: &crate::db::RepoRecord, + caller: &str, + verified: Option<&VerifiedUcan>, +) -> bool { crate::api::did_matches(caller, &record.owner_did) + || verified.is_some_and(|v| ucan_grants_push(record, v)) +} + +/// Whether `with` names this repository. +/// +/// Structural, not a string compare: `owner_did` is stored as a full +/// `did:key:z6Mk…` on canonical rows and as a bare `z6Mk…` on mirror rows, so a +/// literal match would deny a valid delegation for every mirror. +/// +/// A resource wildcard is REFUSED here, deliberately, even though +/// [`gitlawb_core::ucan::Capability::is_attenuated_by`] accepts `"*"` as a parent. +/// `git-remote-gitlawb` narrows a wildcard delegation to the concrete repo before +/// signing an invocation, but that is one client's courtesy, not a protocol +/// boundary: a delegate can sign an otherwise valid `agent -> node` invocation that +/// keeps `with: "*"`, and it passes attenuation because its proof is `"*"` too. +/// Accepting it would let a single delegation push to every repository the root DID +/// owns, including repositories created AFTER the delegation was issued — exactly +/// the growth `docs/RUN-A-NODE.md` promises cannot happen. An authorization +/// boundary cannot enforce that by trusting a client-side representation change, +/// so the node requires a concrete resource of its own. +fn repo_capability_matches(with: &str, record: &crate::db::RepoRecord) -> bool { + let Some(rest) = with.strip_prefix("gitlawb://repos/") else { + return false; + }; + // The owner segment is a DID and may contain ':' but never '/', so the last + // separator splits owner from name. + let Some((owner_seg, name_seg)) = rest.rsplit_once('/') else { + return false; + }; + !owner_seg.is_empty() + && crate::api::did_matches(owner_seg, &record.owner_did) + && name_seg == record.name +} + +/// Whether a verified UCAN authorizes a push to `record`. +/// +/// Two conditions, both required: +/// 1. The chain roots at this repo's owner. This is the trust anchor — the +/// repo record is data the node holds independently of the token, so a +/// self-minted chain cannot satisfy it. +/// 2. Some capability in the leaf covers `git/push` on this repo. +/// +/// Only the leaf is examined for (2): [`gitlawb_core::ucan::Ucan::verify_chain`] +/// has already established that each leaf capability is attenuated by its proof, +/// transitively to the root, so a surviving leaf capability is no broader than +/// what the root granted. +/// +/// A capability carrying `nb` (constraints) authorizes nothing. Constraints are +/// not interpreted yet, and an owner who writes them means to restrict; granting +/// while ignoring them would be strictly more permissive than intended. +pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool { + if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) { + return false; + } + // A write capability must lapse on its own. `exp` is optional in the format and + // there is no revocation path, so a chain with an unbounded link is a permanent + // grant: once the token leaks, the owner cannot withdraw it short of rotating + // the DID the repo is keyed on. Refusing here is what makes "the damage window + // is the token's expiry" a true statement rather than an aspiration. + if !verified.ucan.chain_lifetime_is_bounded() { + return false; + } + if !verified + .ucan + .payload + .att + .iter() + .any(|cap| push_class_names_repo(cap, record)) + { + return false; + } + // EVERY link must name this repository, not just the leaf. + // + // Refusing a wildcard leaf alone did nothing: `is_attenuated_by` accepts a + // concrete child under a `*` parent, and that narrowing is exactly what + // `build_invocation` performs. So one owner-issued `with: "*"` proof let the + // delegate mint a concrete leaf for ANY repository the owner has — or creates + // later — and both the chain check and the owner-root check passed. The scope + // of a delegation is fixed when it is issued, so a proof that does not name + // this repository cannot authorize a push to it. + proofs_name_repo(&verified.ucan, record) +} + +/// A capability that is push-class, unconstrained, and names this repository. +fn push_class_names_repo( + cap: &gitlawb_core::ucan::Capability, + record: &crate::db::RepoRecord, +) -> bool { + cap.constraints.is_none() + && (cap.can == gitlawb_core::ucan::caps::GIT_PUSH + || cap.can == "*" + || cap.can == gitlawb_core::ucan::caps::REPO_ADMIN) + && repo_capability_matches(&cap.with, record) +} + +/// Every proof in the chain carries a capability naming this repository. +/// +/// `verify_chain` has already established that the chain is internally consistent +/// and that each link attenuates its parent — but attenuation permits narrowing a +/// `*` parent to a concrete child, which is the growth this refuses. Called after +/// `verify_chain`, so `prf` is at most one entry per link and the depth is bounded +/// by a chain that already walked successfully. +fn proofs_name_repo(ucan: &gitlawb_core::ucan::Ucan, record: &crate::db::RepoRecord) -> bool { + for proof_token in &ucan.payload.prf { + let Ok(proof) = gitlawb_core::ucan::Ucan::decode(proof_token) else { + return false; + }; + if !proof + .payload + .att + .iter() + .any(|cap| push_class_names_repo(cap, record)) + { + return false; + } + if !proofs_name_repo(&proof, record) { + return false; + } + } + true } use gitlawb_core::http_sig::{ @@ -270,7 +409,7 @@ fn validate_ucan_chain( token: &str, expected_aud: &Did, signer_did: &Did, -) -> Result<(), (StatusCode, Json)> { +) -> Result)> { let ucan = Ucan::decode(token).map_err(|e| { ( StatusCode::UNAUTHORIZED, @@ -298,14 +437,14 @@ fn validate_ucan_chain( ) })?; - ucan.verify_chain().map_err(|e| { + let root = ucan.verify_chain().map_err(|e| { ( StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid_ucan", "message": e.to_string() })), ) })?; - Ok(()) + Ok(VerifiedUcan { ucan, root }) } /// Axum middleware that validates a UCAN chain when `X-Ucan` is present. @@ -358,11 +497,18 @@ pub async fn require_ucan_chain( } }; - if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) { - return (status, body).into_response(); - } + let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) { + Ok(v) => v, + Err((status, body)) => return (status, body).into_response(), + }; - tracing::debug!(did = %signer_did, "UCAN chain validated"); + tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated"); + + // Park the verified token where a handler can reach it. Validation alone + // grants nothing; the authorization decision is made downstream, by a caller + // that knows which identity it trusts for the resource being touched. + let mut request = request; + request.extensions_mut().insert(verified); next.run(request).await } @@ -398,6 +544,37 @@ mod tests { Ucan::bootstrap(node, agent_did).unwrap() } + /// The middleware validated a token and threw the result away, so no handler + /// could ever read it and `Ucan::can` had no call site in the node. Validation + /// must hand back both the token and the root the chain rests on. + #[test] + fn validate_ucan_chain_hands_back_the_root_and_the_token() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)]; + + let delegation = + Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation"); + let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation) + .expect("wrap invocation"); + let token = invocation.encode().expect("encode"); + + let verified = validate_ucan_chain(&token, &node.did(), &agent.did()) + .expect("a well-formed owner-rooted invocation must validate"); + + assert_eq!( + verified.root, + owner.did(), + "the root must be the owner, so a caller can anchor against the repo record" + ); + assert_eq!( + verified.ucan.payload.iss, + agent.did(), + "the token itself must come back so a caller can read its capabilities" + ); + } + fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan { Ucan::delegate( agent, @@ -627,3 +804,279 @@ mod tests { assert_eq!(body_json["error"], "invalid_ucan"); } } + +#[cfg(test)] +mod ucan_push_tests { + use super::*; + use gitlawb_core::identity::Keypair; + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + /// `RepoRecord` does not derive `Default`, and adding the derive to a + /// production DB type purely to serve a test is the wrong direction. + fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord { + crate::db::RepoRecord { + id: "repo-id".to_string(), + name: name.to_string(), + owner_did: owner_did.to_string(), + description: None, + is_public: true, + default_branch: "main".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/unused".to_string(), + forked_from: None, + machine_id: None, + } + } + + /// The token's own issuer and audience are irrelevant to this predicate: the + /// middleware has already bound `iss` to the request signer and `aud` to this + /// node. Only the capabilities and the chain's root matter here. + fn verified(root: &str, caps_vec: Vec) -> VerifiedUcan { + verified_with_exp( + root, + caps_vec, + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + } + + fn verified_with_exp( + root: &str, + caps_vec: Vec, + exp: Option>, + ) -> VerifiedUcan { + let agent = Keypair::generate(); + let node = Keypair::generate(); + let ucan = Ucan::issue(&agent, node.did(), caps_vec, exp).expect("issue"); + VerifiedUcan { + ucan, + root: root.parse().expect("root DID must parse"), + } + } + + fn owner_full() -> String { + format!("did:key:{OWNER_KEY}") + } + + fn push_cap_for(owner: &str, name: &str) -> Capability { + Capability::new(format!("gitlawb://repos/{owner}/{name}"), caps::GIT_PUSH) + } + + #[test] + fn grants_push_when_the_chain_roots_at_the_owner_and_names_the_repo() { + let rec = repo(&owner_full(), "myrepo"); + let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]); + assert!(ucan_grants_push(&rec, &v)); + } + + #[test] + fn matches_a_bare_owner_key_against_a_full_did_record() { + // Mirror rows store the bare key. A literal string compare would fail + // here, denying a delegation that is in fact valid. + let rec = repo(OWNER_KEY, "myrepo"); + let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]); + assert!(ucan_grants_push(&rec, &v)); + } + + /// A perpetual grant is refused even when it is otherwise perfectly valid: + /// owner-rooted, right repo, right action. Without revocation, an unbounded + /// delegation cannot be withdrawn once it leaks. + #[test] + fn refuses_a_delegation_that_never_expires() { + let rec = repo(&owner_full(), "myrepo"); + let v = verified_with_exp( + &owner_full(), + vec![push_cap_for(&owner_full(), "myrepo")], + None, + ); + assert!(!ucan_grants_push(&rec, &v)); + } + + #[test] + fn refuses_a_self_minted_root() { + // The whole point: a token nobody delegated grants nothing, however + // permissive its capabilities look. + let stranger = Keypair::generate(); + let rec = repo(&owner_full(), "myrepo"); + let v = verified(&stranger.did().to_string(), vec![Capability::new("*", "*")]); + assert!(!ucan_grants_push(&rec, &v)); + } + + #[test] + fn refuses_a_capability_for_a_different_repo() { + let rec = repo(&owner_full(), "myrepo"); + let v = verified( + &owner_full(), + vec![push_cap_for(&owner_full(), "otherrepo")], + ); + assert!(!ucan_grants_push(&rec, &v)); + } + + #[test] + fn refuses_a_capability_carrying_constraints() { + // `nb` is not interpreted yet. An owner who writes {"refs": [...]} means + // to restrict; honouring the capability while ignoring nb would grant + // strictly more than they intended, so it authorizes nothing. + let rec = repo(&owner_full(), "myrepo"); + let v = verified( + &owner_full(), + vec![push_cap_for(&owner_full(), "myrepo") + .with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }))], + ); + assert!(!ucan_grants_push(&rec, &v)); + } + + #[test] + fn refuses_a_non_push_capability() { + let rec = repo(&owner_full(), "myrepo"); + let v = verified( + &owner_full(), + vec![Capability::new( + format!("gitlawb://repos/{}/myrepo", owner_full()), + caps::ISSUE_CREATE, + )], + ); + assert!(!ucan_grants_push(&rec, &v)); + } + + /// A resource wildcard must NOT authorize a push, even though attenuation + /// accepts it. The helper narrows a `*` delegation before signing, but the node + /// cannot rely on that: a delegate can sign an invocation that keeps the + /// wildcard, and it would otherwise reach every repository the owner has — or + /// will later create. This test previously asserted the opposite. + #[test] + fn refuses_a_resource_wildcard_and_honours_repo_admin() { + let rec = repo(&owner_full(), "myrepo"); + let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]); + assert!( + !ucan_grants_push(&rec, &wildcard), + "a wildcard resource must not authorize a push at the node" + ); + + // The ACTION wildcard is a different axis and stays: attenuation bounds it, + // and it still has to name a concrete repository. + let action_wildcard = verified( + &owner_full(), + vec![Capability::new( + format!("gitlawb://repos/{}/myrepo", owner_full()), + "*", + )], + ); + assert!(ucan_grants_push(&rec, &action_wildcard)); + + let admin = verified( + &owner_full(), + vec![Capability::new( + format!("gitlawb://repos/{}/myrepo", owner_full()), + caps::REPO_ADMIN, + )], + ); + assert!(ucan_grants_push(&rec, &admin)); + } + + /// The attack the wildcard refusal exists to stop: one `*` delegation reaching a + /// repository it was never issued against, including one created afterwards. + #[test] + fn a_wildcard_delegation_cannot_reach_a_second_repository() { + let other = repo(&owner_full(), "a-repo-created-later"); + let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]); + assert!(!ucan_grants_push(&other, &wildcard)); + } + + /// The round-8 P1, executed rather than reasoned: an owner-issued `*` PROOF with + /// a concrete leaf for a repository that did not exist at issuance. Refusing a + /// wildcard *leaf* did nothing here — `is_attenuated_by` accepts a concrete child + /// under a `*` parent, and that narrowing is exactly what `build_invocation` did, + /// so the first-party helper was the working mint path. + #[test] + fn a_wildcard_proof_cannot_reach_a_repo_created_later() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + + let parent = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + Some(hour), + ) + .unwrap(); + let later = format!("gitlawb://repos/{}/a-repo-created-later", owner.did()); + let invocation = Ucan::delegate( + &agent, + node.did(), + vec![Capability::new(&later, caps::GIT_PUSH)], + Some(hour), + &parent, + ) + .unwrap(); + + let root = invocation.verify_chain().expect("the chain still verifies"); + let rec = repo(&owner.did().to_string(), "a-repo-created-later"); + assert!( + !ucan_grants_push( + &rec, + &VerifiedUcan { + ucan: invocation, + root + } + ), + "a wildcard proof must not authorize a repo it never named" + ); + } + + /// The shipping flow must keep working: a proof that names the repository + /// authorizes a push to it. Guards against fixing the wildcard by refusing + /// everything with a `prf`. + #[test] + fn a_concrete_proof_still_authorizes_the_repo_it_names() { + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let resource = format!("gitlawb://repos/{}/myrepo", owner.did()); + + let parent = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new(&resource, caps::GIT_PUSH)], + Some(hour), + ) + .unwrap(); + let invocation = Ucan::delegate( + &agent, + node.did(), + vec![Capability::new(&resource, caps::GIT_PUSH)], + Some(hour), + &parent, + ) + .unwrap(); + + let root = invocation.verify_chain().expect("chain verifies"); + let rec = repo(&owner.did().to_string(), "myrepo"); + assert!(ucan_grants_push( + &rec, + &VerifiedUcan { + ucan: invocation, + root + } + )); + } + + #[test] + fn refuses_a_malformed_resource_uri() { + let rec = repo(&owner_full(), "myrepo"); + for bad in [ + "", + "myrepo", + "https://repos/x/myrepo", + "gitlawb://repos/myrepo", + ] { + let v = verified(&owner_full(), vec![Capability::new(bad, caps::GIT_PUSH)]); + assert!(!ucan_grants_push(&rec, &v), "{bad} must not grant push"); + } + } +} diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 2b5aef95..7d91be02 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -2172,6 +2172,266 @@ mod tests { ); } + /// Delegated push, end to end through both auth layers. + /// + /// A non-owner presenting an invocation whose chain roots at the repo owner + /// clears the owner-push gate; the same signer without one, and with one that + /// names a different repository, are both refused. This is the regression the + /// owner-push default introduced: a CI or delegated key holding a valid + /// `git/push` capability was refused exactly like a stranger. + /// + /// Status codes are the discriminators. 500 means the request passed + /// `require_signature` (not 401), passed `require_ucan_chain` (not 401), and + /// cleared the owner gate (not 403), then reached git on a repo with no disk + /// backing — the same shape `git_upload_pack_post_is_read_gated_on_private_repo` + /// relies on. A bare `!= 403` would let a 401 regression pass. + /// + /// Not `#[cfg(unix)]`: no fake-git shim is involved, only HTTP and the gate. + #[sqlx::test] + async fn delegated_push_clears_the_owner_gate(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + use gitlawb_core::ucan::{caps, Capability, Ucan}; + use std::sync::Arc; + + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let owner_did = owner.did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + + let mut state = test_state(pool).await; + // Explicit rather than relying on the shipped default, so this test states + // the configuration it is about. + let mut cfg = (*state.config).clone(); + cfg.enforce_owner_push = true; + state.config = Arc::new(cfg); + + state + .db + .create_repo(&seed_repo(&owner_did, "deleg")) + .await + .expect("seed repo"); + + let router = || { + Router::new() + .route( + "/{owner}/{repo}/git-receive-pack", + axum::routing::post(crate::api::repos::git_receive_pack), + ) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::auth::require_ucan_chain, + )) + .layer(axum::middleware::from_fn(crate::auth::require_signature)) + .with_state(state.clone()) + }; + + let path = format!("/{short}/deleg.git/git-receive-pack"); + let body = b"0000".to_vec(); + + // owner -> agent delegation, then agent -> node invocation carrying it. + // Both links carry a finite expiry: a write capability that never lapses is + // refused, since there is no revocation path to withdraw a leaked one. + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let invocation_with_exp = |resource: String, exp: Option>| { + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new(resource, caps::GIT_PUSH)], + exp, + ) + .expect("issue delegation"); + Ucan::delegate( + &agent, + state.node_did.clone(), + delegation.payload.att.clone(), + exp, + &delegation, + ) + .expect("wrap invocation") + .encode() + .expect("encode invocation") + }; + let invocation_for = |resource: String| invocation_with_exp(resource, Some(hour)); + + let signed_push = |ucan: Option| { + let signed = sign_request(&agent, "POST", &path, &body); + let mut req = Request::builder() + .method(Method::POST) + .uri(&path) + .header("content-type", "application/x-git-receive-pack-request") + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature); + if let Some(token) = ucan { + req = req.header("x-ucan", token); + } + req.body(Body::from(body.clone())).expect("request") + }; + + // 1. Valid delegation for THIS repo: clears the gate. + let resp = router() + .oneshot(signed_push(Some(invocation_for(format!( + "gitlawb://repos/{owner_did}/deleg" + ))))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "an owner-rooted git/push delegation must clear the owner gate and reach git" + ); + + // 2. Delegation naming a DIFFERENT repo: refused. + let other = router() + .oneshot(signed_push(Some(invocation_for(format!( + "gitlawb://repos/{owner_did}/someotherrepo" + ))))) + .await + .unwrap(); + assert_eq!( + other.status(), + StatusCode::FORBIDDEN, + "a delegation for another repository must not authorize this push" + ); + let other_body = axum::body::to_bytes(other.into_body(), 4096).await.unwrap(); + + // 3. No delegation at all: refused, with a byte-identical body. A caller + // must not be able to tell a non-applicable delegation from none, or the + // denial becomes an oracle for which capabilities exist. + let none = router().oneshot(signed_push(None)).await.unwrap(); + assert_eq!( + none.status(), + StatusCode::FORBIDDEN, + "a non-owner with no delegation must still be refused" + ); + let none_body = axum::body::to_bytes(none.into_body(), 4096).await.unwrap(); + assert_eq!( + other_body, none_body, + "an inapplicable delegation and no delegation must be indistinguishable" + ); + + // 4. A delegation that never expires: refused, however otherwise valid. + // Owner-rooted, right repo, right action — but with no revocation path an + // unbounded grant cannot be withdrawn once the token leaks. + let perpetual = router() + .oneshot(signed_push(Some(invocation_with_exp( + format!("gitlawb://repos/{owner_did}/deleg"), + None, + )))) + .await + .unwrap(); + assert_eq!( + perpetual.status(), + StatusCode::FORBIDDEN, + "a delegation with no expiry must not authorize a push" + ); + } + + /// A valid delegation clears the owner gate but is still refused on a branch + /// the owner has explicitly protected. + /// + /// Two predicates deliberately disagree: the owner gate accepts a delegate, the + /// branch-protection loop is owner-only. A protected branch is the owner's + /// marker that even routine writes should stop, so a `git/push` delegation must + /// not silently override it — otherwise issuing any capability would weaken + /// every protection the owner had already set. + /// + /// The 403 body is the discriminator: it must name the branch, proving the + /// request reached branch protection rather than being turned away by the owner + /// gate for lacking a delegation. + #[sqlx::test] + async fn delegated_push_is_still_refused_on_a_protected_branch(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + use gitlawb_core::ucan::{caps, Capability, Ucan}; + use std::sync::Arc; + + const ZERO: &str = "0000000000000000000000000000000000000000"; + let new_sha = "1111111111111111111111111111111111111111"; + + let owner = Keypair::generate(); + let agent = Keypair::generate(); + let owner_did = owner.did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + + let mut state = test_state(pool).await; + let mut cfg = (*state.config).clone(); + cfg.enforce_owner_push = true; + state.config = Arc::new(cfg); + + let rec = seed_repo(&owner_did, "protrepo"); + state.db.create_repo(&rec).await.expect("seed repo"); + state + .db + .protect_branch(&rec.id, "main", &owner_did) + .await + .expect("protect main"); + + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{owner_did}/protrepo"), + caps::GIT_PUSH, + )], + Some(hour), + ) + .expect("issue delegation"); + let invocation = Ucan::delegate( + &agent, + state.node_did.clone(), + delegation.payload.att.clone(), + Some(hour), + &delegation, + ) + .expect("wrap") + .encode() + .expect("encode"); + + let router = Router::new() + .route( + "/{owner}/{repo}/git-receive-pack", + axum::routing::post(crate::api::repos::git_receive_pack), + ) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::auth::require_ucan_chain, + )) + .layer(axum::middleware::from_fn(crate::auth::require_signature)) + .with_state(state.clone()); + + let path = format!("/{short}/protrepo.git/git-receive-pack"); + let line = format!("{ZERO} {new_sha} refs/heads/main"); + let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes(); + + let signed = sign_request(&agent, "POST", &path, &body); + let req = Request::builder() + .method(Method::POST) + .uri(&path) + .header("content-type", "application/x-git-receive-pack-request") + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .header("x-ucan", invocation) + .body(Body::from(body)) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a delegation must not override branch protection" + ); + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!( + text.contains("protected"), + "the refusal must come from branch protection, not the owner gate; got {text}" + ); + } + /// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after /// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack /// POST against a private repo is denied (404), so signing only the Phase-1 diff --git a/crates/gl/src/doctor.rs b/crates/gl/src/doctor.rs index 86f50334..ff7ac39d 100644 --- a/crates/gl/src/doctor.rs +++ b/crates/gl/src/doctor.rs @@ -24,7 +24,7 @@ pub struct DoctorArgs { #[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")] pub node: String, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] pub dir: Option, } @@ -73,17 +73,18 @@ pub async fn run(args: DoctorArgs) -> Result<()> { println!("gl doctor — checking your gitlawb setup"); println!(); - let dir = args.dir.unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".gitlawb") - }); + // The same resolver every other command uses. `doctor` reporting on + // `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would + // make the one command whose job is to explain a broken setup the one that + // misreports it. + let args_dir = args.dir.clone(); + let dir = crate::identity::gitlawb_dir(args.dir)?; let mut checks = Vec::new(); let mut all_ok = true; // ── 1. Identity ─────────────────────────────────────────────────────── - let pem_path = dir.join("identity.pem"); + let pem_path = crate::identity::key_path_for(args_dir.as_deref())?; if pem_path.exists() { match std::fs::read_to_string(&pem_path) .ok() diff --git a/crates/gl/src/identity.rs b/crates/gl/src/identity.rs index bde5c94c..6044737d 100644 --- a/crates/gl/src/identity.rs +++ b/crates/gl/src/identity.rs @@ -9,7 +9,8 @@ use std::path::{Path, PathBuf}; pub enum IdentityCmd { /// Generate a new Ed25519 keypair and DID New { - /// Output directory for key files (default: ~/.gitlawb) + /// Output directory for key files + /// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] dir: Option, /// Overwrite existing keys if present @@ -63,16 +64,51 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> { } } -fn gitlawb_dir(override_dir: Option) -> Result { +/// Resolve the identity directory, honouring an explicit override. +/// Public so sibling commands (`gl ucan import`, `gl doctor`) look in the same +/// place the identity itself lives. +/// +/// Without an override this is [`gitlawb_core::identity_path::identity_dir`] — the +/// parent of `GITLAWB_KEY`, else `~/.gitlawb`. The rules live in `gitlawb-core` +/// because `git-remote-gitlawb` needs the identical answer: an operator who moved +/// their key (`GITLAWB_KEY=/data/keys/identity.pem`, the shape `.env.example` +/// documents) would otherwise have `gl ucan import` write the delegation to +/// `~/.gitlawb/delegations` while the helper reads `/data/keys/delegations` and +/// finds it empty. The push then goes out with no `X-Ucan` and the delegate is +/// refused, with nothing on either side to indicate why. +pub fn gitlawb_dir(override_dir: Option) -> Result { if let Some(d) = override_dir { return Ok(d); } - let home = dirs::home_dir().context("could not determine home directory")?; - Ok(home.join(".gitlawb")) + // `home_dir()` is passed as an Option rather than demanded here: an absolute + // GITLAWB_KEY resolves without a home directory, and a host that has none is a + // normal container shape, not a reason to refuse a correctly-configured key. + let home = dirs::home_dir(); + gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}")) } -fn key_path(dir: &Path) -> PathBuf { - dir.join("identity.pem") +/// The identity PEM to read or write. +/// +/// With an explicit `--dir` this is `/identity.pem`, the conventional layout. +/// Without one it is [`gitlawb_core::identity_path::identity_key_path`] — the whole +/// of `GITLAWB_KEY`, basename included. +/// +/// Taking only the parent and re-appending `identity.pem` was a real divergence, +/// not a tidy-up: `GITLAWB_KEY` is documented as a path to a PEM, and +/// `git-remote-gitlawb` opens exactly that path. With +/// `GITLAWB_KEY=/data/keys/ci-agent.pem`, `gl identity new` wrote +/// `/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so +/// the two either disagreed on identity or the helper found no key at all — and +/// owner enforcement and the delegation proof both key off that identity. +pub(crate) fn key_path_for(dir: Option<&Path>) -> Result { + match dir { + Some(d) => Ok(d.join(gitlawb_core::identity_path::KEY_FILE_NAME)), + None => { + let home = dirs::home_dir(); + gitlawb_core::identity_path::identity_key_path(home.as_deref()) + .map_err(|e| anyhow::anyhow!("{e}")) + } + } } fn load_keypair(dir: Option) -> Result { @@ -81,15 +117,14 @@ fn load_keypair(dir: Option) -> Result { /// Load keypair from an optional directory override. /// Used by other modules (register, repo, mcp). +/// +/// Routed through [`gitlawb_dir`] rather than reaching for `~/.gitlawb` directly: +/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so a second +/// resolver here would have every other command read a different file than the one +/// just created — `gl ucan delegate` would either fail to find an identity or sign +/// with a stale DID that is not the repo owner. pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result { - let base = if let Some(d) = dir { - d.to_path_buf() - } else { - dirs::home_dir() - .context("could not determine home directory")? - .join(".gitlawb") - }; - let path = key_path(&base); + let path = key_path_for(dir)?; let pem = fs::read_to_string(&path).with_context(|| { format!( "no identity found at {}\nRun `gl identity new` to create one", @@ -108,8 +143,11 @@ async fn cmd_new_with_reader( force: bool, reader: &mut impl std::io::BufRead, ) -> Result<()> { - let dir = gitlawb_dir(dir)?; - let path = key_path(&dir); + let path = key_path_for(dir.as_deref())?; + let dir = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); if path.exists() { if force { @@ -183,8 +221,7 @@ async fn cmd_sign(message: String, dir: Option) -> Result<()> { } async fn cmd_backup(out: Option, dir: Option) -> Result<()> { - let base = gitlawb_dir(dir)?; - let src = key_path(&base); + let src = key_path_for(dir.as_deref())?; let pem = fs::read_to_string(&src).with_context(|| { format!( @@ -239,8 +276,11 @@ async fn cmd_restore_with_reader( // Verify it's a valid keypair before writing anything let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?; - let base = gitlawb_dir(dir)?; - let dest = key_path(&base); + let dest = key_path_for(dir.as_deref())?; + let base = dest + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); if dest.exists() { if force { @@ -509,3 +549,182 @@ mod tests { assert_eq!(original_did, dst_kp.did()); } } + +/// Scoped `GITLAWB_KEY` for tests, shared crate-wide. +/// +/// The process environment is global and more than one suite in this crate +/// depends on it — the resolver's own cases here, and `gl register`'s check that +/// the bootstrap token lands beside the key. They take one lock rather than each +/// declaring its own, which would not serialise them against each other. +#[cfg(test)] +pub(crate) mod test_env { + use std::ffi::{OsStr, OsString}; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Restores the previous value and releases the lock on drop. + pub(crate) struct KeyEnv { + _guard: MutexGuard<'static, ()>, + restore: Option, + } + + impl Drop for KeyEnv { + fn drop(&mut self) { + match self.restore.take() { + Some(v) => std::env::set_var("GITLAWB_KEY", v), + None => std::env::remove_var("GITLAWB_KEY"), + } + } + } + + /// Run `f` with `GITLAWB_KEY` set, restoring it afterwards. + pub(crate) fn with_key>(value: Option, f: impl FnOnce() -> T) -> T { + let _guard = set_key(value); + f() + } + + /// Set `GITLAWB_KEY` (or remove it, for `None`) until the guard drops. + pub(crate) fn set_key>(value: Option) -> KeyEnv { + let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let restore = std::env::var_os("GITLAWB_KEY"); + match value { + Some(v) => std::env::set_var("GITLAWB_KEY", v), + None => std::env::remove_var("GITLAWB_KEY"), + } + KeyEnv { + _guard: guard, + restore, + } + } +} + +#[cfg(test)] +mod gitlawb_dir_tests { + use super::{gitlawb_dir, load_keypair_from_dir}; + use std::ffi::OsString; + use std::path::PathBuf; + + /// Run `f` with `GITLAWB_KEY` set to `value` (or removed for `None`), restoring + /// whatever was there before. + fn with_key_env(value: Option, f: impl FnOnce() -> T) -> T { + let _env = crate::identity::test_env::set_key(value); + f() + } + + /// An explicit --dir always wins and is never validated against GITLAWB_KEY. + #[test] + fn explicit_override_wins() { + let d = PathBuf::from("/tmp/explicit"); + assert_eq!(gitlawb_dir(Some(d.clone())).unwrap(), d); + } + + /// A relative GITLAWB_KEY must fail loudly. `gl` and `git-remote-gitlawb` run + /// from different working directories, so resolving one relatively sends the + /// import and the lookup to different stores; a one-component value yields an + /// empty parent and puts the store in `./delegations`. + #[test] + fn relative_key_paths_are_refused() { + for raw in ["identity.pem", "keys/identity.pem"] { + let result = with_key_env(Some(OsString::from(raw)), || gitlawb_dir(None)); + assert!(result.is_err(), "{raw} is relative and must be refused"); + } + } + + /// An empty value is what a shell leaves behind for `FOO=` and for an unset + /// variable expanded into a wrapper script. Treated as unset, not as an error. + #[test] + fn an_empty_key_path_is_treated_as_unset() { + let result = with_key_env(Some(OsString::new()), || gitlawb_dir(None)); + assert_eq!( + result.unwrap(), + dirs::home_dir().unwrap().join(".gitlawb"), + "an empty value selects the default directory" + ); + } + + /// The reason `gitlawb_dir` reads through `var_os`: `env::var` folds a non-UTF-8 + /// value into the same `Err` as unset, so a bad path would silently resolve to + /// `~/.gitlawb` instead of being reported. Byte 0xFF is not valid UTF-8 in any + /// position, so this value is unreachable through `env::var`. + #[cfg(unix)] + #[test] + fn a_non_utf8_key_path_is_not_mistaken_for_unset() { + use std::os::unix::ffi::OsStringExt; + + let raw = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec()); + let result = with_key_env(Some(raw), || gitlawb_dir(None)); + + let err = result.expect_err("a non-UTF-8 relative path must be refused"); + assert!( + err.to_string().contains("absolute"), + "the error must name the real problem, not fall back to the default: {err}" + ); + + let mut absolute = OsString::from("/data/"); + absolute.push(OsString::from_vec(vec![0xFF])); + absolute.push("/identity.pem"); + let resolved = with_key_env(Some(absolute.clone()), || gitlawb_dir(None)).unwrap(); + assert_eq!( + resolved, + PathBuf::from(&absolute).parent().unwrap(), + "an absolute non-UTF-8 path resolves to its own parent, not to ~/.gitlawb" + ); + } + + /// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so every + /// other command has to read it back from there. `load_keypair_from_dir(None)` + /// used to hardcode `~/.gitlawb`, which made `gl ucan delegate` sign with a + /// stale DID — or fail outright — for exactly the operators who moved the key. + #[test] + fn load_keypair_from_dir_honours_the_key_env() { + let dir = tempfile::tempdir().unwrap(); + let key = dir.path().join("identity.pem"); + let expected = gitlawb_core::identity::Keypair::generate(); + std::fs::write(&key, expected.to_pem().unwrap()).unwrap(); + + let loaded = with_key_env(Some(key.into_os_string()), || load_keypair_from_dir(None)) + .expect("the identity beside GITLAWB_KEY must be found"); + + assert_eq!(loaded.did(), expected.did()); + } +} + +#[cfg(test)] +mod key_basename_tests { + use super::*; + + /// `GITLAWB_KEY` names a FILE. `gl` used to keep only its parent and re-append + /// `identity.pem`, while `git-remote-gitlawb` opened the configured path — so + /// with `GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI and the push path loaded + /// different files, and owner enforcement and the delegation proof both key off + /// whichever identity that was. + #[test] + fn a_non_default_key_basename_is_honoured() { + let dir = tempfile::tempdir().unwrap(); + let key = dir.path().join("ci-agent.pem"); + let expected = gitlawb_core::identity::Keypair::generate(); + std::fs::write(&key, expected.to_pem().unwrap()).unwrap(); + + let (resolved, loaded) = crate::identity::test_env::with_key(Some(key.clone()), || { + (key_path_for(None).unwrap(), load_keypair_from_dir(None)) + }); + + assert_eq!(resolved, key, "the configured basename must survive"); + assert_eq!( + loaded.expect("the key at GITLAWB_KEY must load").did(), + expected.did(), + "gl must load the same file the helper opens" + ); + } + + /// An explicit --dir keeps the conventional layout. + #[test] + fn an_explicit_dir_still_uses_identity_pem() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + key_path_for(Some(dir.path())).unwrap(), + dir.path().join("identity.pem") + ); + } +} diff --git a/crates/gl/src/init.rs b/crates/gl/src/init.rs index 1bc3c406..cf89108a 100644 --- a/crates/gl/src/init.rs +++ b/crates/gl/src/init.rs @@ -22,7 +22,7 @@ pub struct InitArgs { #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] pub node: String, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] pub dir: Option, @@ -101,10 +101,7 @@ pub async fn run(args: InitArgs) -> Result<()> { // Save UCAN if returned if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) { if !ucan.is_empty() { - let ucan_dir = args - .dir - .clone() - .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb")); + let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?; std::fs::create_dir_all(&ucan_dir)?; let record = json!({ "ucan": ucan, @@ -221,18 +218,15 @@ pub async fn run(args: InitArgs) -> Result<()> { } fn generate_identity(dir: Option<&std::path::Path>) -> Result { - let base = if let Some(d) = dir { - d.to_path_buf() - } else { - dirs::home_dir() - .context("could not determine home directory")? - .join(".gitlawb") - }; + let path = crate::identity::key_path_for(dir)?; + let base = path + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| std::path::PathBuf::from(".")); std::fs::create_dir_all(&base)?; let keypair = gitlawb_core::identity::Keypair::generate(); let pem = keypair.to_pem()?; - let path = base.join("identity.pem"); #[cfg(unix)] { diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index d4cb64fc..782cd8cd 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -23,7 +23,7 @@ pub enum IpfsCmd { List { #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] node: String, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] dir: Option, }, diff --git a/crates/gl/src/mcp.rs b/crates/gl/src/mcp.rs index ae319c73..ff7865d9 100644 --- a/crates/gl/src/mcp.rs +++ b/crates/gl/src/mcp.rs @@ -283,7 +283,7 @@ fn tool_definitions() -> Value { }, { "name": "ucan_show", - "description": "Show the saved bootstrap UCAN token for this agent.", + "description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.", "inputSchema": { "type": "object", "properties": {} } }, { @@ -759,15 +759,45 @@ async fn call_tool( ]))?), "ucan_show" => { - let ucan_path = dirs::home_dir() - .context("no home dir")? - .join(".gitlawb/ucan.json"); - if ucan_path.exists() { - let content = std::fs::read_to_string(ucan_path)?; - Ok(content) - } else { - Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string()) + // The directory the server was started with, like every sibling tool. + // Reading the default here while the rest honour `--dir` splits one MCP + // session across two identity directories. + let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))? + .join("ucan.json"); + if !ucan_path.exists() { + return Ok( + "No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(), + ); } + let content = std::fs::read_to_string(&ucan_path)?; + // Decoded fields, matching what `gl ucan show` reports — the two are the + // same question asked through different surfaces and should not answer + // it in different shapes. + // + // Returning the file verbatim also handed the caller the bootstrap token + // itself. That is the credential the agent presents, and an MCP response + // travels further than a terminal: into a model's context, transcripts, + // and logs. The fields below are what a caller actually needs to know + // whether it is registered and until when. + let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| { + format!("could not read the saved UCAN at {}", ucan_path.display()) + })?; + Ok(serde_json::to_string_pretty(&json!({ + "issuer": ucan.payload.iss.to_string(), + "audience": ucan.payload.aud.to_string(), + "version": ucan.payload.ucan, + "capabilities": ucan.payload.att.iter() + .map(|c| json!({ "with": c.with, "can": c.can })) + .collect::>(), + "expires": ucan.payload.exp.map(|e| { + chrono::DateTime::from_timestamp(e, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| e.to_string()) + }), + "expired": ucan.is_expired(), + "signature_valid": ucan.verify_signature().is_ok(), + "path": ucan_path.display().to_string(), + }))?) } "did_resolve" => { @@ -2033,3 +2063,87 @@ mod tests { assert_eq!(count, 40, "expected 40 tools, got {count}"); } } + +#[cfg(test)] +mod ucan_show_tests { + use super::*; + + /// The MCP tool and `gl ucan show` answer the same question and must answer it + /// in the same shape — decoded fields, not the file. Returning the file verbatim + /// also handed back the bootstrap token, which an MCP response carries much + /// further than a terminal does. + #[tokio::test] + async fn ucan_show_returns_decoded_fields_and_withholds_the_token() { + let dir = tempfile::TempDir::new().unwrap(); + let issuer = gitlawb_core::identity::Keypair::generate(); + let audience = gitlawb_core::identity::Keypair::generate(); + let token = gitlawb_core::ucan::Ucan::issue( + &issuer, + audience.did(), + vec![gitlawb_core::ucan::Capability::new( + "gitlawb://repos/z6MkAbc/myrepo", + gitlawb_core::ucan::caps::GIT_PUSH, + )], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap(); + + // The envelope shape every writer produces. + std::fs::write( + dir.path().join("ucan.json"), + serde_json::to_string_pretty(&json!({ + "ucan": token, + "node": "https://node.gitlawb.com", + "did": issuer.did().to_string(), + "saved_at": "2026-08-18T00:00:00Z", + })) + .unwrap(), + ) + .unwrap(); + + let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path())) + .await + .expect("ucan_show must read the envelope every writer produces"); + let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON"); + + assert_eq!(v["issuer"], issuer.did().to_string()); + assert_eq!(v["audience"], audience.did().to_string()); + assert_eq!(v["capabilities"][0]["can"], "git/push"); + assert_eq!(v["expired"], false); + assert_eq!(v["signature_valid"], true); + + // NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in + // a pretty-printed response escapes every quote and that assertion passes + // whether or not the token leaked. The signature is a bare base64 string that + // survives escaping unchanged, so it is the substring that actually proves + // absence. + let sig = serde_json::from_str::(&token).unwrap()["s"] + .as_str() + .expect("a UCAN carries its signature in `s`") + .to_string(); + assert!(!sig.is_empty()); + assert!( + !out.contains(&sig), + "the bootstrap token must not be returned to an MCP caller" + ); + assert!( + serde_json::from_str::(&out) + .unwrap() + .get("ucan") + .is_none(), + "no field may carry the token itself" + ); + } + + /// An unregistered agent gets a usable message, not a decode error. + #[tokio::test] + async fn ucan_show_reports_no_saved_ucan() { + let dir = tempfile::TempDir::new().unwrap(); + let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path())) + .await + .unwrap(); + assert!(out.contains("No UCAN saved"), "got: {out}"); + } +} diff --git a/crates/gl/src/name.rs b/crates/gl/src/name.rs index 9840518b..b9a27782 100644 --- a/crates/gl/src/name.rs +++ b/crates/gl/src/name.rs @@ -168,16 +168,8 @@ pub async fn run(args: NameArgs) -> Result<()> { // ── Helpers ─────────────────────────────────────────────────────────────────── -fn identity_dir(dir: Option) -> PathBuf { - dir.unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".gitlawb") - }) -} - fn load_did(dir: Option) -> Result { - let path = identity_dir(dir).join("identity.pem"); + let path = crate::identity::key_path_for(dir.as_deref())?; let pem = std::fs::read_to_string(&path).with_context(|| { format!( "No identity at {} — run `gl identity new` first", @@ -190,7 +182,7 @@ fn load_did(dir: Option) -> Result { } fn load_did_and_document(dir: Option) -> Result<(String, String)> { - let path = identity_dir(dir).join("identity.pem"); + let path = crate::identity::key_path_for(dir.as_deref())?; let pem = std::fs::read_to_string(&path).with_context(|| { format!( "No identity at {} — run `gl identity new` first", diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 367ba576..abd1de98 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -22,7 +22,7 @@ pub enum NodeCmd { Status { #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] node: String, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] dir: Option, }, diff --git a/crates/gl/src/node_stake.rs b/crates/gl/src/node_stake.rs index 869afcf8..49858c12 100644 --- a/crates/gl/src/node_stake.rs +++ b/crates/gl/src/node_stake.rs @@ -328,12 +328,10 @@ pub async fn cmd_unstake( // ── Helpers ───────────────────────────────────────────────────────────────── fn load_did(dir: Option) -> Result { - let base = dir.unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".gitlawb") - }); - let path = base.join("identity.pem"); + // The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the + // key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing + // home made the answer depend on the working directory. + let path = crate::identity::key_path_for(dir.as_deref())?; let pem = std::fs::read_to_string(&path).with_context(|| { format!( "No identity at {} — run `gl identity new` first", diff --git a/crates/gl/src/quickstart.rs b/crates/gl/src/quickstart.rs index 8b901ad9..24ec3495 100644 --- a/crates/gl/src/quickstart.rs +++ b/crates/gl/src/quickstart.rs @@ -23,7 +23,7 @@ pub struct QuickstartArgs { #[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")] pub node: String, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] pub dir: Option, @@ -39,19 +39,26 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { println!("and create your first repository."); println!(); - let dir = args.dir.clone().unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".gitlawb") - }); + // The wizard generates the identity AND stores the bootstrap UCAN, so it has to + // land where every later command reads from — the parent of `GITLAWB_KEY`, not + // an unconditional `~/.gitlawb`. + let dir = crate::identity::gitlawb_dir(args.dir.clone())?; // ── Step 1: Identity ────────────────────────────────────────────────── println!("── Step 1: Identity ─────────────────────────────────────────────────"); println!(); - let pem_path = dir.join("identity.pem"); + let pem_path = crate::identity::key_path_for(args.dir.as_deref())?; let keypair = if pem_path.exists() { - match load_keypair_from_dir(Some(&dir)) { + // `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the + // loader re-derive the basename as `identity.pem`, so with + // `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem` + // while the load looked for a sibling that need not exist — and the Err arm + // below then regenerated ONTO `ci-agent.pem`, destroying a working key and + // changing the DID that repository ownership, registrations, and delegations + // are all tied to. Regeneration must follow a failure to read the file that + // was actually selected, never the absence of a conventional sibling. + match load_keypair_from_dir(args.dir.as_deref()) { Ok(kp) => { let did = kp.did(); println!(" ✓ Identity already exists"); @@ -59,14 +66,15 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { println!(); kp } - Err(_) => { - println!(" Identity file exists but is unreadable. Regenerating..."); - generate_identity(&dir)? + Err(e) => { + println!(" Identity at {} is unreadable: {e}", pem_path.display()); + println!(" Regenerating — the previous key cannot be recovered."); + generate_identity(&dir, &pem_path)? } } } else { println!(" No identity found. Generating a new Ed25519 keypair..."); - generate_identity(&dir)? + generate_identity(&dir, &pem_path)? }; let did = keypair.did().to_string(); @@ -99,7 +107,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { Ok(resp) if resp.status().is_success() => { let payload: Value = resp.json().await.unwrap_or_default(); let ucan = payload["ucan"].as_str().unwrap_or(""); - if !ucan.is_empty() { + // The messaging below derives from what was actually persisted, not + // from the 2xx: a success response carrying no `ucan` skips the write, + // and saying "UCAN saved" anyway sends the operator looking for a file + // that was never created — then resurfaces later as a push rejection + // with nothing pointing back here. + let saved = if ucan.is_empty() { + false + } else { std::fs::create_dir_all(&dir)?; let record = json!({ "ucan": ucan, @@ -108,11 +123,20 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { "saved_at": chrono::Utc::now().to_rfc3339(), }); std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?; - } + true + }; let trust = payload["trust_score"].as_f64().unwrap_or(0.0); println!(" ✓ Registered successfully"); println!(" Trust score: {trust:.2}"); - println!(" UCAN saved to {}", ucan_path.display()); + if saved { + println!(" UCAN saved to {}", ucan_path.display()); + } else { + println!(" The node returned no bootstrap UCAN, so registration-gated"); + println!( + " workflows are unavailable. Retry with: gl register --node {}", + args.node + ); + } println!(); } Ok(resp) => { @@ -226,22 +250,24 @@ pub async fn run(args: QuickstartArgs) -> Result<()> { // ── Helpers ─────────────────────────────────────────────────────────────── -fn generate_identity(dir: &PathBuf) -> Result { +fn generate_identity( + dir: &PathBuf, + path: &std::path::Path, +) -> Result { std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; let keypair = gitlawb_core::identity::Keypair::generate(); let pem = keypair.to_pem()?; - let path = dir.join("identity.pem"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::write(&path, pem.as_bytes())?; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + std::fs::write(path, pem.as_bytes())?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } #[cfg(not(unix))] { - std::fs::write(&path, pem.as_bytes())?; + std::fs::write(path, pem.as_bytes())?; } let did = keypair.did(); diff --git a/crates/gl/src/register.rs b/crates/gl/src/register.rs index 8a17a77e..466f87d2 100644 --- a/crates/gl/src/register.rs +++ b/crates/gl/src/register.rs @@ -1,7 +1,8 @@ //! `gl register` — register this agent identity with a gitlawb node. //! //! Sends a signed POST /api/register request and saves the returned bootstrap -//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands. +//! UCAN token as `ucan.json` beside the identity key, where the other commands +//! look for it. use anyhow::{Context, Result}; use clap::Args; @@ -29,7 +30,7 @@ pub struct RegisterArgs { #[arg(long)] pub model: Option, - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] pub dir: Option, } @@ -69,17 +70,20 @@ pub async fn run(args: RegisterArgs) -> Result<()> { // Save bootstrap UCAN let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or(""); - if !ucan.is_empty() { - let ucan_path = ucan_path(args.dir.as_deref())?; + let saved_to = if ucan.is_empty() { + None + } else { + let path = ucan_path(args.dir.as_deref())?; let record = json!({ "ucan": ucan, "node": args.node, "did": did.to_string(), "saved_at": chrono::Utc::now().to_rfc3339(), }); - std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?; - tracing::debug!("saved UCAN to {}", ucan_path.display()); - } + std::fs::write(&path, serde_json::to_string_pretty(&record)?)?; + tracing::debug!("saved UCAN to {}", path.display()); + Some(path) + }; let trust = payload .get("trust_score") @@ -99,21 +103,35 @@ pub async fn run(args: RegisterArgs) -> Result<()> { println!(" Trust score: {trust:.2}"); println!(" UCAN expires: {expires}"); println!(); - println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json"); - println!(" You are now a verified agent on the gitlawb network."); + // The real path, not the default one: `GITLAWB_KEY` moves it, and an operator + // told to look in `~/.gitlawb` would find nothing there. + // Registration without a stored token means the registration-gated + // capabilities never arrived, so the closing line must not claim they did. + match &saved_to { + Some(path) => { + println!(" Bootstrap UCAN saved to {}", path.display()); + println!(" You are now a verified agent on the gitlawb network."); + } + None => { + println!(" The node returned no bootstrap UCAN, so this identity is not"); + println!(" a verified agent yet. Re-run `gl register` once the node issues one."); + } + } Ok(()) } +/// Where the bootstrap UCAN is stored: beside the identity key, always. +/// +/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The +/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere +/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`, +/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's +/// directory and report an unregistered identity. fn ucan_path(dir: Option<&std::path::Path>) -> Result { - let base = if let Some(d) = dir { - d.to_path_buf() - } else { - dirs::home_dir() - .context("could not determine home directory")? - .join(".gitlawb") - }; - std::fs::create_dir_all(&base)?; + let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?; + std::fs::create_dir_all(&base) + .with_context(|| format!("failed to create {}", base.display()))?; Ok(base.join("ucan.json")) } @@ -160,6 +178,48 @@ mod tests { assert_eq!(content["node"].as_str().unwrap(), server.url()); } + /// `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 + /// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks + /// beside the key, finds nothing, and reports an unregistered identity. + #[tokio::test] + async fn register_saves_the_bootstrap_ucan_beside_the_key() { + let dir = TempDir::new().unwrap(); + write_identity(&dir); + // No --dir: the destination has to come from GITLAWB_KEY alone. + let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem"))); + + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("POST", "/api/register") + .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: None, + }) + .await + .unwrap(); + + let beside_the_key = dir.path().join("ucan.json"); + assert!( + beside_the_key.exists(), + "the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb" + ); + let content: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap(); + assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token"); + } + #[tokio::test] async fn test_register_server_error() { let dir = TempDir::new().unwrap(); diff --git a/crates/gl/src/ucan_cmd.rs b/crates/gl/src/ucan_cmd.rs index 99d8841c..17720d23 100644 --- a/crates/gl/src/ucan_cmd.rs +++ b/crates/gl/src/ucan_cmd.rs @@ -6,7 +6,7 @@ use serde_json::json; use std::path::PathBuf; use gitlawb_core::did::Did; -use gitlawb_core::ucan::{Capability, Ucan}; +use gitlawb_core::ucan::{caps, Capability, Ucan}; use crate::identity::load_keypair_from_dir; @@ -29,9 +29,17 @@ pub enum UcanCmd { /// Action, e.g. "git/push", "pr/open", "repo/admin" #[arg(long)] can: String, - /// Expiry in hours (default: no expiry) - #[arg(long)] - expiry: Option, + /// Expiry in hours. Defaults to 720 (30 days). + /// + /// A capability that authorizes a write must lapse on its own: there is no + /// revocation path yet, so an unbounded delegation cannot be withdrawn once + /// the token leaks. A node refuses an unbounded `git/push` chain outright. + #[arg(long, default_value_t = DEFAULT_DELEGATION_EXPIRY_HOURS)] + expiry: u64, + /// Issue with no expiry. The result cannot authorize a push, and cannot be + /// withdrawn — only use it for advisory or read-shaped capabilities. + #[arg(long, conflicts_with = "expiry")] + no_expiry: bool, /// Save the UCAN to a file instead of printing #[arg(long)] out: Option, @@ -53,8 +61,73 @@ pub enum UcanCmd { /// UCAN JSON token (or path to file containing it) token: String, }, + /// Store a delegation received from a repo owner, so `git push` can present it + Import { + /// UCAN JSON token (or path to a file containing it) + token: String, + /// Identity directory + #[arg(long)] + dir: Option, + }, +} + +/// Where a delegation for `owner_did`/`repo` is stored. +/// +/// Keyed on the bare base58 key rather than the full DID: `did:key:` contains a +/// colon, which is not a legal filename character on Windows, and the same +/// identity appears in both forms across this codebase — storing under one form +/// and looking up by the other would silently miss. +/// +/// `git-remote-gitlawb` derives the same path from a `gitlawb://` URL alone; the +/// two must agree, and the helper carries a pointer back to this function. +pub fn delegation_path(dir: &std::path::Path, owner_did: &str, repo: &str) -> PathBuf { + let bare = owner_did.strip_prefix("did:key:").unwrap_or(owner_did); + dir.join("delegations").join(format!("{bare}__{repo}.ucan")) +} + +/// A path component that is safe to build a filename from. +/// +/// This is load-bearing, not defensive tidiness: the values it guards flow into +/// [`delegation_path`], which `gl ucan import` WRITES to, and they come from a +/// field of an untrusted token. `Path::join` with an absolute component discards +/// the base entirely, so an owner of `/etc/cron.d/x` or `C:/Windows/...` escapes +/// the delegations directory completely rather than merely climbing out of it. +/// +/// Deliberately an allow-list. A DID carries `:` (`did:key:z6Mk…`) and repo names +/// carry `.`, `-` and `_`; nothing else is needed, and a deny-list of separators +/// would miss whichever ones the next platform introduces. +fn is_safe_component(s: &str) -> bool { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains("..") + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')) +} + +/// Pull the repo this capability names out of `gitlawb://repos//`. +/// +/// Requires exactly two components after the prefix. Anything else — extra +/// segments, a trailing slash, an empty half — is refused rather than +/// interpreted, so no input can address a location the caller did not intend. +fn repo_from_resource(with: &str) -> Option<(String, String)> { + let rest = with.strip_prefix("gitlawb://repos/")?; + let mut parts = rest.split('/'); + let owner = parts.next()?; + let name = parts.next()?; + if parts.next().is_some() { + return None; + } + if !is_safe_component(owner) || !is_safe_component(name) { + return None; + } + Some((owner.to_string(), name.to_string())) } +/// Default delegation lifetime. Finite on purpose: an unbounded write capability +/// cannot be withdrawn while there is no revocation path, and the node refuses one. +pub const DEFAULT_DELEGATION_EXPIRY_HOURS: u64 = 720; + pub async fn run(args: UcanArgs) -> Result<()> { match args.cmd { UcanCmd::Delegate { @@ -62,13 +135,291 @@ pub async fn run(args: UcanArgs) -> Result<()> { cap, can, expiry, + no_expiry, out, dir, json: json_out, - } => cmd_delegate(to, cap, can, expiry, out, dir, json_out).await, + } => { + let exp_hours = if no_expiry { None } else { Some(expiry) }; + cmd_delegate(to, cap, can, exp_hours, out, dir, json_out).await + } UcanCmd::Show { dir } => cmd_show(dir).await, UcanCmd::Verify { token } => cmd_verify(token).await, + UcanCmd::Import { token, dir } => cmd_import(token, dir).await, + } +} + +/// Store a delegation where `git-remote-gitlawb` will look for it on push. +/// +/// The token is decoded here rather than at push time so a malformed delegation +/// fails where the error is actionable, instead of surfacing as an unexplained +/// 403 in the middle of a `git push`. +async fn cmd_import(token: String, dir: Option) -> Result<()> { + let raw = match std::fs::read_to_string(&token) { + Ok(contents) => contents.trim().to_string(), + Err(_) => token.clone(), + }; + + let ucan = Ucan::decode(&raw).context( + "not a valid UCAN token — pass the JSON emitted by `gl ucan delegate`, or a path to it", + )?; + + // The audience has to be THIS identity. The node requires `proof.aud` to equal + // the invocation issuer, so a token addressed to someone else is unusable here + // however well-formed it is: the helper would sign as us, the proof would name + // them, and the node would refuse the linkage — a 403 with nothing locally to + // explain it. Import is the last cheap place to say so. + let me = crate::identity::load_keypair_from_dir(dir.as_deref()) + .context("cannot tell who this delegation is for without a local identity")?; + let my_did = me.did().to_string(); + if !did_eq(&ucan.payload.aud.to_string(), &my_did) { + anyhow::bail!( + "this delegation is addressed to {}, but the local identity is {my_did}. \ + Ask the owner to re-issue it with `--to {my_did}`.", + ucan.payload.aud + ); + } + + // Verify before it can displace a working delegation: a token the node would + // refuse is not worth overwriting a good one for. + let root = ucan + .verify_chain() + .map_err(|e| anyhow::anyhow!("this delegation does not verify: {e}"))?; + if ucan.is_expired() { + anyhow::bail!("this delegation has already expired"); + } + if !ucan.chain_lifetime_is_bounded() { + anyhow::bail!( + "this delegation has an unbounded link, and a node refuses an unbounded push chain" + ); + } + tracing::debug!("delegation verified, rooted at {root}"); + + // Import admits only what the push path can actually use, by the SAME rule the + // helper and the node apply — `is_push_class` plus `constraints.is_none()`. + // Three independent definitions of "usable for push" is how a token gets + // accepted at one stage and guaranteed to fail at the next: a constrained-only + // grant used to import cleanly, then be skipped by `build_invocation` and + // treated as granting nothing by `ucan_grants_push`. + // + // The owner segment is checked against the VERIFIED ROOT, not trusted from the + // token. `verify_chain` proves a chain is internally valid; it says nothing + // about which repository that chain applies to. Without this, any key holder + // could issue a valid bounded token to this agent naming + // `gitlawb://repos//repo` and displace the working delegation for a + // repository they have no authority over — the node would refuse the push + // later, but the good credential would already be gone. + let mut push_caps: Vec<(String, String)> = Vec::new(); + let mut rejected_owner: Vec = Vec::new(); + for cap in &ucan.payload.att { + if !is_push_class(&cap.can) || cap.constraints.is_some() { + continue; + } + let Some((owner, repo)) = repo_from_resource(&cap.with) else { + continue; + }; + if !did_eq(&owner, &root.to_string()) { + rejected_owner.push(cap.with.clone()); + continue; + } + push_caps.push((owner, repo)); + } + + // Validate every entry before writing any of them, so a later bad capability + // cannot leave a multi-repository import half applied. + if !rejected_owner.is_empty() { + anyhow::bail!( + "this delegation names repositories owned by someone other than the \ + chain's root issuer ({root}): {}\n\ + The root is the identity the whole chain rests on, so a capability for \ + another owner cannot have come from them and will be refused on push.", + rejected_owner.join(", ") + ); + } + + if push_caps.is_empty() { + anyhow::bail!( + "this delegation carries no storable push capability — expected {} or {} \ + (or \"*\" as the action) on gitlawb://repos//, unconstrained, \ + found: {}\n\ + A \"*\" RESOURCE cannot be imported: the store is keyed by repository, and \ + a wildcard cannot say which repositories it covered when issued: re-issue \ + it against the repository you intend to push to. A \ + capability carrying `nb` cannot be used either — constraints are refused \ + rather than interpreted, so it would authorize nothing on push.", + caps::GIT_PUSH, + caps::REPO_ADMIN, + ucan.payload + .att + .iter() + .map(|c| format!( + "{} -> {}{}", + c.with, + c.can, + if c.constraints.is_some() { + " (constrained)" + } else { + "" + } + )) + .collect::>() + .join(", ") + ); + } + + // The identity directory is a private-data contract, not a public one: it + // already holds `identity.pem`, whose disclosure is strictly worse than a + // delegation's. `create_private_dir` and `write_private_file` below carry the + // per-platform reasoning. + let base = crate::identity::gitlawb_dir(dir)?; + let store = base.join("delegations"); + create_private_dir(&store).with_context(|| format!("could not create {}", store.display()))?; + + for (owner, repo) in &push_caps { + let path = delegation_path(&base, owner, repo); + // 0600, like the sibling identity key. The token is not itself sufficient to + // push — the node requires `iss` to equal the request signer, so a reader + // still needs the delegate's private key — but it does disclose the + // delegation graph and which identities hold capabilities on which repos. + write_private_file(&path, raw.as_bytes()) + .with_context(|| format!("could not write {}", path.display()))?; + println!("Stored delegation for {owner}/{repo} at {}", path.display()); + } + + Ok(()) +} + +/// Actions the push path accepts. Kept in step with the filter in +/// `git-remote-gitlawb`'s `build_invocation`, which is what actually mints an +/// invocation from a stored delegation. +fn is_push_class(can: &str) -> bool { + can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN +} + +/// Create the delegation store owner-only, with no window at a wider mode. +/// +/// `create_dir_all` followed by `set_permissions` leaves the directory at the +/// process umask — 0755 under the usual 022 — until the second call lands, which +/// is long enough for another local user to open it. The mode rides on the +/// creating syscall instead. The follow-up `set_permissions` is not the window +/// reopening: it only matters when the directory already existed, and repairs a +/// 0755 store left behind by an older `gl`. +#[cfg(unix)] +fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + std::fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) +} + +/// Write `contents`, owner-only from the moment the file exists. +/// +/// Not `create_new`: re-importing a refreshed delegation has to overwrite the +/// stored one. `mode` applies only when the file is created, so the trailing +/// `set_permissions` covers a 0644 file written by an older `gl`. +/// A staging path unique to this call, in the same directory as `path`. +/// +/// A single deterministic `..tmp` is shared by every importer for a +/// repository: two concurrent refreshes truncate and write the same inode, and one +/// can rename bytes the other validated, reporting success for a token it never +/// published. Process id plus a monotonic counter keeps them apart. +fn staging_path(path: &std::path::Path) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + dir.join(format!( + ".{}.{}.{}.tmp", + path.file_name().unwrap_or_default().to_string_lossy(), + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )) +} + +#[cfg(unix)] +fn write_private_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + // Staged, then renamed. Opening the live path with `truncate(true)` empties a + // working delegation before the replacement is written, so an interruption, + // ENOSPC, or short write leaves an unreadable token and pushes that silently + // drop `X-Ucan`. `rename` within a directory is atomic: either the old token or + // the new one is there, never half of either. + let tmp = staging_path(path); + + let write = || -> std::io::Result<()> { + // `create_new`: the staging path is this operation's alone, so colliding + // with an existing one is a bug to surface rather than a file to clobber. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + file.write_all(contents)?; + // Durable before it becomes live: a rename that beats the data to disk can + // surface an empty file after a crash. + file.sync_all()?; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + }; + + if let Err(e) = write() { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +// `std::fs` has no portable ACL API, and `gitlawb_dir` accepts any directory, so +// the contract off Unix is that the caller supplies a user-private directory — +// which is what the platform's per-user profile gives by default. The private key +// sits in the same directory under the same assumption, and its disclosure is +// strictly worse than a delegation's, so hardening this one file alone would be +// theatre. +#[cfg(not(unix))] +fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(path) +} + +#[cfg(not(unix))] +fn write_private_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + // `fs::write` opens the live delegation with truncation, so a failed or + // interrupted refresh destroyed a working token here even though the Unix path + // staged first. The refresh contract is the same on every platform: failure + // preserves the old credential, success publishes one complete new one. + use std::io::Write; + let tmp = staging_path(path); + + let write = || -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + file.write_all(contents)?; + file.sync_all() + }; + + if let Err(e) = write() { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + // Windows `rename` refuses an existing destination, so the live file is removed + // first. That window is why this is second-best to the Unix path: it can leave + // the delegation absent, but never truncated or half-written, and re-importing + // restores it. + if path.exists() { + let _ = std::fs::remove_file(path); } + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) } async fn cmd_delegate( @@ -85,6 +436,17 @@ async fn cmd_delegate( .parse() .map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?; + // A push-class wildcard cannot be honoured: the node requires every link in the + // chain to name the repository, because a delegation's scope is fixed when it is + // issued and a bare `*` cannot express which repositories it covered at that + // moment. Refusing at issuance beats minting a token that imports cleanly and + // then fails every push. + if cap == "*" && (can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN) { + anyhow::bail!( + "a wildcard resource cannot carry a push capability: a delegation is scoped \n to the repositories it names when issued, and `*` cannot say which those \n were. Re-run with --cap gitlawb://repos//." + ); + } + let exp = expiry.map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); let ucan = Ucan::issue(&keypair, audience, vec![Capability::new(&cap, &can)], exp)?; let encoded = ucan.encode()?; @@ -127,10 +489,7 @@ async fn cmd_delegate( } async fn cmd_show(dir: Option) -> Result<()> { - let home = dir - .or_else(|| dirs::home_dir().map(|h| h.join(".gitlawb"))) - .context("cannot find identity directory")?; - let ucan_path = home.join("ucan.json"); + let ucan_path = crate::identity::gitlawb_dir(dir)?.join("ucan.json"); if !ucan_path.exists() { println!("No UCAN saved. Run `gl register` first."); @@ -138,7 +497,8 @@ async fn cmd_show(dir: Option) -> Result<()> { } let content = std::fs::read_to_string(&ucan_path)?; - let ucan = Ucan::decode(&content)?; + let ucan = decode_saved_ucan(&content) + .with_context(|| format!("could not read the saved UCAN at {}", ucan_path.display()))?; println!("Issuer: {}", ucan.payload.iss); println!("Audience: {}", ucan.payload.aud); @@ -348,3 +708,467 @@ mod tests { .unwrap(); } } + +#[cfg(test)] +mod delegation_store_tests { + use super::*; + + /// `repo_from_resource` feeds `delegation_path`, which builds a filesystem + /// path that `gl ucan import` then WRITES to — from a field of an untrusted + /// token. A separator, a parent-directory hop, or an absolute prefix in the + /// owner escapes the delegations directory; `Path::join` with an absolute + /// component discards the base entirely, so an absolute owner writes anywhere + /// the user can write. + #[test] + fn repo_from_resource_rejects_anything_that_could_escape_the_store() { + for bad in [ + "gitlawb://repos/../../evil/x", + "gitlawb://repos/../x", + "gitlawb://repos/a/../../x", + "gitlawb://repos//x", + "gitlawb://repos/C:/Windows/System32/x", + "gitlawb://repos//etc/cron.d/x", + "gitlawb://repos/a\\b/x", + "gitlawb://repos/owner/sub/dir/x", + "gitlawb://repos/owner/x/", + "gitlawb://repos/owner/", + "gitlawb://repos/owner", + "gitlawb://repos/", + "gitlawb://repos/owner/..", + "gitlawb://repos/owner/.", + "gitlawb://repos/./x", + "https://repos/owner/x", + "", + ] { + assert!( + repo_from_resource(bad).is_none(), + "{bad:?} must not yield a storable owner/repo pair" + ); + } + } + + #[test] + fn repo_from_resource_accepts_the_canonical_shape() { + assert_eq!( + repo_from_resource("gitlawb://repos/did:key:z6MkAbc/myrepo"), + Some(("did:key:z6MkAbc".to_string(), "myrepo".to_string())) + ); + assert_eq!( + repo_from_resource("gitlawb://repos/z6MkAbc/my-repo.rs"), + Some(("z6MkAbc".to_string(), "my-repo.rs".to_string())) + ); + } + + #[test] + fn delegation_path_strips_the_did_prefix_and_separates_owner_from_repo() { + let base = std::path::Path::new("/tmp/id"); + let expected = base.join("delegations").join("z6MkAbc__myrepo.ucan"); + + assert_eq!( + delegation_path(base, "did:key:z6MkAbc", "myrepo"), + expected, + "the bare key keys the file: `did:key:` contains ':', which is not a \ + legal filename character on Windows" + ); + // A bare owner and a full DID must resolve to the same file, or a + // delegation stored under one form is invisible to a lookup by the other. + assert_eq!( + delegation_path(base, "z6MkAbc", "myrepo"), + expected, + "bare and full owner forms must address the same delegation" + ); + } + + /// Seed `dir` with a local identity and issue a delegation addressed to it. + /// + /// Import now binds the token's audience to the local key, so a fixture that + /// issues to an unrelated DID is testing the audience check rather than + /// whatever it meant to test. + pub(super) fn seed_identity(dir: &std::path::Path) -> gitlawb_core::identity::Keypair { + let kp = gitlawb_core::identity::Keypair::generate(); + std::fs::write(dir.join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap(); + kp + } + + /// A delegation whose resource owner is the issuing owner — the shape import + /// now requires, since the owner segment is checked against the verified root. + /// Returns the token and the bare owner key the store is keyed on. + pub(super) fn owned_token( + agent: &gitlawb_core::identity::Keypair, + can: &str, + repo: &str, + ) -> (String, String) { + let owner = gitlawb_core::identity::Keypair::generate(); + let full = owner.did().to_string(); + let bare = full.strip_prefix("did:key:").unwrap().to_string(); + let token = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{bare}/{repo}"), + can, + )], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap(); + (token, bare) + } + + pub(super) fn token_for_agent( + agent: &gitlawb_core::identity::Keypair, + can: &str, + with: &str, + ) -> String { + let owner = gitlawb_core::identity::Keypair::generate(); + Ucan::issue( + &owner, + agent.did(), + vec![Capability::new(with, can)], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap() + } + + /// A delegation the push path cannot use must fail at import, where the + /// operator is watching. `build_invocation` requires a push-class action, so a + /// `pr/open` token that imported "successfully" would be silently dropped from + /// the push behind a `tracing::warn` and surface only as a 403 with no + /// connection to the earlier success. + #[tokio::test] + async fn import_refuses_a_delegation_the_push_path_cannot_use() { + for can in ["pr/open", "issue/create", "git/fetch"] { + let dir = tempfile::tempdir().unwrap(); + let agent = seed_identity(dir.path()); + let (token, _owner) = owned_token(&agent, can, "myrepo"); + + let err = cmd_import(token, Some(dir.path().to_path_buf())) + .await + .expect_err("{can} is not a push capability and must be refused"); + + assert!( + err.to_string().contains(caps::GIT_PUSH), + "the error must name the action the push path needs: {err}" + ); + assert!( + !dir.path().join("delegations").exists(), + "nothing may be written before the capability is accepted" + ); + } + } + + /// The resource is `*`, so the store — which is keyed by repository — has no + /// filename to write under. Refused with an explanation rather than reported as + /// an import that stored nothing. + #[tokio::test] + async fn import_refuses_a_wildcard_resource() { + let dir = tempfile::tempdir().unwrap(); + let agent = seed_identity(dir.path()); + let token = token_for_agent(&agent, caps::GIT_PUSH, "*"); + + let err = cmd_import(token, Some(dir.path().to_path_buf())) + .await + .expect_err("a wildcard resource cannot be keyed by repository"); + + assert!( + err.to_string().contains("re-issue"), + "the error must say what to do instead: {err}" + ); + assert!(!dir.path().join("delegations").exists()); + } + + #[tokio::test] + async fn import_accepts_every_push_class_action() { + for can in [caps::GIT_PUSH, caps::REPO_ADMIN, "*"] { + let dir = tempfile::tempdir().unwrap(); + let agent = seed_identity(dir.path()); + let (token, owner) = owned_token(&agent, can, "myrepo"); + + cmd_import(token, Some(dir.path().to_path_buf())) + .await + .unwrap_or_else(|e| panic!("{can} must import: {e}")); + + let stored = delegation_path(dir.path(), &owner, "myrepo"); + assert!(stored.exists(), "{can} must leave a stored delegation"); + } + } + + /// The store and the token file must never exist at a wider mode, not even + /// briefly: `create_dir_all` then chmod leaves 0755 under the usual umask, and + /// the token discloses the delegation graph. + #[cfg(unix)] + #[tokio::test] + async fn import_creates_the_store_and_token_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let agent = seed_identity(dir.path()); + let token = token_for_agent(&agent, caps::GIT_PUSH, "gitlawb://repos/z6MkAbc/myrepo"); + cmd_import(token.clone(), Some(dir.path().to_path_buf())) + .await + .unwrap(); + + let store = dir.path().join("delegations"); + let stored = delegation_path(dir.path(), "z6MkAbc", "myrepo"); + assert_eq!( + std::fs::metadata(&store).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(&stored).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + // Re-import has to overwrite, which is why this is not `create_new`. + cmd_import(token, Some(dir.path().to_path_buf())) + .await + .unwrap(); + assert_eq!( + std::fs::metadata(&stored).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +/// Decode the token out of a saved `ucan.json`. +/// +/// `gl register`, `gl init`, and `gl quickstart` all write an envelope — +/// `{"ucan": "", "node": ..., "did": ..., "saved_at": ...}` — and `doctor` +/// and `quickstart` read it back as one. `cmd_show` was the only reader calling +/// `Ucan::decode` on the whole file, and `Ucan` is `{payload, s}`, so it failed +/// with "missing field `payload`" immediately after a successful `gl register`. +/// +/// The bare-token form is still accepted: a file written by an older `gl`, or by +/// hand, should not stop being readable just because the envelope is now canonical. +pub(crate) fn decode_saved_ucan(content: &str) -> Result { + if let Ok(envelope) = serde_json::from_str::(content) { + if let Some(token) = envelope.get("ucan").and_then(|v| v.as_str()) { + return Ucan::decode(token).map_err(Into::into); + } + } + Ucan::decode(content.trim()).map_err(Into::into) +} + +#[cfg(test)] +mod saved_ucan_tests { + use super::*; + + fn a_token() -> String { + let kp = gitlawb_core::identity::Keypair::generate(); + let aud = gitlawb_core::identity::Keypair::generate(); + Ucan::issue( + &kp, + aud.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + None, + ) + .unwrap() + .encode() + .unwrap() + } + + /// The shape `gl register`, `gl init`, and `gl quickstart` all write, and the + /// shape `doctor` and `quickstart` already read back. `cmd_show` used to call + /// `Ucan::decode` on the whole file and failed with "missing field `payload`" + /// immediately after a successful `gl register`. + #[test] + fn the_register_envelope_decodes() { + let token = a_token(); + let envelope = serde_json::json!({ + "ucan": token, + "node": "https://node.gitlawb.com", + "did": "did:key:z6MkAbc", + "saved_at": "2026-08-17T00:00:00Z", + }) + .to_string(); + + let decoded = decode_saved_ucan(&envelope).expect("the written envelope must decode"); + assert_eq!(decoded.encode().unwrap(), token); + } + + /// A file written by an older `gl`, or by hand, stays readable. + #[test] + fn a_bare_token_still_decodes() { + let token = a_token(); + assert_eq!( + decode_saved_ucan(&format!(" {token}\n")) + .expect("a bare token must still decode") + .encode() + .unwrap(), + token + ); + } + + #[test] + fn neither_shape_swallows_garbage() { + assert!(decode_saved_ucan("not a ucan").is_err()); + assert!(decode_saved_ucan(r#"{"node":"x"}"#).is_err()); + } +} + +/// Compare two DIDs ignoring the `did:key:` prefix. +/// +/// The same identity appears in both forms across this codebase — the node stores +/// canonical rows full and mirror rows bare, and `delegation_path` keys on the bare +/// form for filename safety — so a literal string compare would reject a match that +/// every other layer accepts. +fn did_eq(a: &str, b: &str) -> bool { + let bare = |d: &str| d.strip_prefix("did:key:").unwrap_or(d).to_string(); + bare(a) == bare(b) +} + +#[cfg(test)] +mod import_binding_tests { + use super::delegation_store_tests::{owned_token, seed_identity, token_for_agent}; + use super::*; + + /// A valid delegation addressed to somebody else must fail at import, not at + /// push. The node requires `proof.aud == invocation.iss`, so storing it only + /// buys a 403 later with nothing pointing back to the import that caused it. + #[tokio::test] + async fn import_refuses_a_delegation_addressed_to_another_identity() { + let dir = tempfile::tempdir().unwrap(); + let _me = seed_identity(dir.path()); + let someone_else = gitlawb_core::identity::Keypair::generate(); + let token = token_for_agent( + &someone_else, + caps::GIT_PUSH, + "gitlawb://repos/z6MkAbc/myrepo", + ); + + let err = cmd_import(token, Some(dir.path().to_path_buf())) + .await + .expect_err("a delegation for another DID is unusable here"); + + assert!( + err.to_string().contains("addressed to"), + "the error must name the mismatch: {err}" + ); + assert!( + !dir.path().join("delegations").exists(), + "nothing may be stored for a delegation this identity cannot invoke" + ); + } + + /// A REJECTED import must not touch the store. This stops at validation, before + /// `write_private_file` is reached — which is the point: rejection happens ahead + /// of any mutation. `a_failed_write_leaves_the_stored_delegation_intact` covers + /// the writer itself. + #[tokio::test] + async fn a_rejected_import_leaves_the_stored_delegation_intact() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let (good, owner) = owned_token(&me, caps::GIT_PUSH, "myrepo"); + cmd_import(good.clone(), Some(dir.path().to_path_buf())) + .await + .unwrap(); + let stored = delegation_path(dir.path(), &owner, "myrepo"); + let before = std::fs::read_to_string(&stored).unwrap(); + + // A token for the same repo that import must refuse. + let someone_else = gitlawb_core::identity::Keypair::generate(); + let bad = token_for_agent( + &someone_else, + caps::GIT_PUSH, + "gitlawb://repos/z6MkAbc/myrepo", + ); + let _ = cmd_import(bad, Some(dir.path().to_path_buf())).await; + + assert_eq!( + std::fs::read_to_string(&stored).unwrap(), + before, + "a refused import must leave the working delegation exactly as it was" + ); + } + + /// An expired delegation cannot displace a live one either. + #[tokio::test] + async fn import_refuses_an_expired_delegation() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let owner = gitlawb_core::identity::Keypair::generate(); + let expired = Ucan::issue( + &owner, + me.did(), + vec![Capability::new( + "gitlawb://repos/z6MkAbc/myrepo", + caps::GIT_PUSH, + )], + Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap(); + + let err = cmd_import(expired, Some(dir.path().to_path_buf())) + .await + .expect_err("an expired delegation is not importable"); + assert!( + err.to_string().contains("expired"), + "the error must name expiry: {err}" + ); + } +} + +#[cfg(all(test, unix))] +mod refresh_atomicity_tests { + use super::delegation_store_tests::{owned_token, seed_identity, token_for_agent}; + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// The writer's own contract: a failed replacement preserves the old token. + /// + /// The previously named "refused reimport" test never reached + /// `write_private_file` — it stopped at the audience check — so nothing covered + /// the case this exists for. Making the store unwritable forces the failure at + /// the write itself. + #[tokio::test] + async fn a_failed_write_leaves_the_stored_delegation_intact() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + // The resource owner must equal the chain root, which `owned_token` ensures. + let (good, owner_key) = owned_token(&me, caps::GIT_PUSH, "myrepo"); + cmd_import(good.clone(), Some(dir.path().to_path_buf())) + .await + .expect("the first import must succeed"); + + let stored = delegation_path(dir.path(), &owner_key, "myrepo"); + let before = std::fs::read(&stored).unwrap(); + assert!(!before.is_empty()); + + // Make the store read-only so the staging create fails. + let store = dir.path().join("delegations"); + let orig = std::fs::metadata(&store).unwrap().permissions(); + std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = cmd_import(good, Some(dir.path().to_path_buf())).await; + + // Restore before asserting, so a failure here cannot leave a locked tempdir. + std::fs::set_permissions(&store, orig).unwrap(); + + assert!(result.is_err(), "the write must fail on a read-only store"); + assert_eq!( + std::fs::read(&stored).unwrap(), + before, + "a failed refresh must leave the old token complete, not empty or partial" + ); + } + + /// Two refreshes must not share a staging path: one could rename bytes the other + /// validated and report success for a token it never published. + #[test] + fn staging_paths_are_unique_per_call() { + let p = std::path::Path::new("/tmp/store/z6MkAbc__myrepo.ucan"); + let a = staging_path(p); + let b = staging_path(p); + assert_ne!(a, b, "each write needs its own staging file"); + assert_eq!( + a.parent(), + p.parent(), + "staging must be a sibling, for rename" + ); + } +} diff --git a/crates/gl/src/whoami.rs b/crates/gl/src/whoami.rs index 66c1438c..699e8875 100644 --- a/crates/gl/src/whoami.rs +++ b/crates/gl/src/whoami.rs @@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir; #[derive(Args)] pub struct WhoamiArgs { - /// Identity directory (default: ~/.gitlawb) + /// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb) #[arg(long)] dir: Option, /// Node URL to query for registration info diff --git a/docs/RUN-A-NODE.md b/docs/RUN-A-NODE.md index 0a8a0f77..f81f690f 100644 --- a/docs/RUN-A-NODE.md +++ b/docs/RUN-A-NODE.md @@ -155,17 +155,48 @@ To require the authenticated pusher to be the repo owner on **every** branch, se GITLAWB_ENFORCE_OWNER_PUSH=true ``` -- **Default `false`** — preserves current behavior so live nodes are unaffected by - an upgrade. Turn it on once you're ready for owner-only writes. +- **Default `false`** on this release — preserves current behavior so live nodes + are unaffected by an upgrade. Turn it on once you're ready for owner-only writes. - **When `true`** — a push whose authenticated DID is not the repo owner is rejected (HTTP 403) before any ref update is applied. The owner is matched in both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix. -- **Caution: this blocks every non-owner pusher, including your own delegated and - CI agents.** Push authorization is owner-only today — a UCAN `git/push` - capability is verified but not yet honored for authorization, so delegated keys - cannot push while this is on. Don't enable it until every identity that pushes - to your repos is the owner, or you'll lock out your own automation. Scoped - collaborator / UCAN-delegated push rights are a planned follow-up. +- **A delegated key can still push.** A non-owner clears this gate by presenting a + UCAN whose proof chain roots at the repo owner and which carries `git/push` for + this repository. See *Delegating push to a CI agent* below. + +### Delegating push to a CI agent + +The owner issues a capability, the agent stores it, and the git helper presents it +automatically on every push: + +```bash +# Owner, once per agent per repo: +gl ucan delegate --to did:key:z6MkAgent… \ + --cap gitlawb://repos// --can git/push --expiry 168 + +# Agent: +gl ucan import +git push origin main # git-remote-gitlawb attaches it as X-Ucan +``` + +What the node requires, and why: + +| Requirement | Reason | +|---|---| +| The chain's **root issuer** is the repo owner | A `did:key` is self-certifying, so anyone can mint a chain. The owner is the only anchor the node holds independently of the token. | +| The capability names **this** repository | Enforced by the node: a `*` resource is refused outright, so one delegation can never grow to cover repos the owner creates later. `git-remote-gitlawb` also narrows a `*` delegation to the concrete repo when it builds the invocation, but that is convenience — the node does not rely on it. | +| **Every link carries an expiry** | There is no revocation path yet. An unbounded delegation could never be withdrawn once leaked, so the node refuses one outright. `gl ucan delegate` defaults to 30 days. | +| No `nb` constraints | Constraints are reserved but not yet interpreted, so a capability carrying them authorizes nothing rather than silently granting more than the owner intended. | + +**A delegation does not override branch protection.** A protected branch is your +explicit marker that even routine writes should stop, so a delegate is still +refused there and only the owner may push. That is deliberate: if a delegation +overrode it, issuing any capability would weaken every protection you had set. + +**Withdrawal is by expiry only.** There is no revocation today. If a delegated +token leaks, it remains valid until its `exp`, and the only faster remedy is +rotating the owner DID the repository is keyed on. Choose `--expiry` accordingly — +short lifetimes reissued often are safer than one long-lived grant. ---