diff --git a/.env.example b/.env.example index 81c60824..c7832c8b 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 @@ -98,9 +101,10 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false # push to every repository, private ones included. On by default. # # Set to false only for a rolling upgrade whose pushers are not yet the repo -# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is -# verified but not yet honored for authorization, so they cannot push while this -# is on. See docs/RUN-A-NODE.md. +# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that +# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and +# importing one. A CI key with no such delegation counts as a non-owner and +# cannot push. GITLAWB_ENFORCE_OWNER_PUSH=true # Comma-separated libp2p multiaddrs. diff --git a/Cargo.lock b/Cargo.lock index 3f29b076..d78f9c78 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 3a092bf2..48619525 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ 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. +- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, on by default since #330. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere. +- 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. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). 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. @@ -311,7 +311,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. | @@ -393,7 +393,7 @@ Important node settings: | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | | `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | -| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | +| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | | `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. | | `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. | | `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. | diff --git a/crates/git-remote-gitlawb/Cargo.toml b/crates/git-remote-gitlawb/Cargo.toml index b704329e..ec2d6c0f 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", features = ["redirect"] } 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 738839c1..69b5003c 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\ @@ -408,6 +410,205 @@ 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}; + + let resource = format!("gitlawb://repos/{owner}/{repo}"); + + // The whole chain has to grant push to this repository, by the same rule the + // node applies (`chain_grants_push_to`): a stored token whose proof is still a + // wildcard would mint an invocation the node is certain to refuse, and the push + // would fail remotely with a vaguer message than this one. Checked before the + // leaf is selected so the message names the actual reason. + if !delegation.chain_grants_push_to(owner, repo) { + anyhow::bail!( + "stored delegation does not grant git/push for {resource} on every link of + its chain — a proof behind it may be a wildcard or name another repository. + Ask the owner to re-issue it against this repository and re-run + `gl ucan import`." + ); + } + let source = delegation + .payload + .att + .iter() + .find(|c| c.grants_push_to(owner, repo)) + .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, @@ -426,6 +627,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)"); } @@ -792,7 +1002,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; @@ -815,16 +1025,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 ───────────────────────────────────────────────────────────────────── @@ -2616,3 +2848,612 @@ 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 stored two-link delegation whose leaf names this repo but whose proof is + /// `*`. The leaf alone would select fine; the node's chain walk refuses it, so + /// the helper refuses it first with a message that says why. + #[test] + fn build_invocation_refuses_a_concrete_leaf_on_a_wildcard_proof() { + use gitlawb_core::ucan::{caps, Capability, Ucan}; + + let owner = Keypair::generate(); + let intermediary = Keypair::generate(); + let agent = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let owner_bare = owner + .did() + .to_string() + .strip_prefix("did:key:") + .unwrap() + .to_string(); + + let proof = Ucan::issue( + &owner, + intermediary.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + Some(hour), + ) + .expect("issue"); + let stored = Ucan::delegate( + &intermediary, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{owner_bare}/r"), + caps::GIT_PUSH, + )], + Some(hour), + &proof, + ) + .expect("re-delegate"); + + let err = build_invocation(&agent, &node.did(), &stored, &owner_bare, "r") + .expect_err("a wildcard proof must fail closed in the helper"); + assert!( + err.to_string().contains("every link"), + "the message must point at the chain, not the leaf: {err}" + ); + } + + /// 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 d0edec0a..9d88a13c 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; // `url` is the one dependency here that drags a tail (idna, then the icu // crates), and gitlawb-core is allowlisted to stay embeddable. Every client that // needs this predicate already parses URLs, so they opt in and nothing else diff --git a/crates/gitlawb-core/src/sanitize.rs b/crates/gitlawb-core/src/sanitize.rs index 2ccf9e68..d16f1f36 100644 --- a/crates/gitlawb-core/src/sanitize.rs +++ b/crates/gitlawb-core/src/sanitize.rs @@ -30,10 +30,30 @@ pub fn is_bidi_format(c: char) -> bool { ) } +/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs +/// ANSI and OSC escapes) and every bidi/format control that +/// [`is_bidi_format`] names. What remains is safe to write to a terminal. +/// +/// Length is not this function's business: a caller that shows the text +/// decides how much of it to show. +pub fn strip_terminal_controls(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control() && !is_bidi_format(*c)) + .collect() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn strip_removes_escapes_and_reordering_but_keeps_text() { + let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}"; + let clean = strip_terminal_controls(hostile); + assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}"); + assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c))); + } + #[test] fn strips_every_reordering_code_point() { // Asserted individually so a partial predicate (e.g. one that covers the diff --git a/crates/gitlawb-core/src/ucan.rs b/crates/gitlawb-core/src/ucan.rs index 86b3dd9f..112064be 100644 --- a/crates/gitlawb-core/src/ucan.rs +++ b/crates/gitlawb-core/src/ucan.rs @@ -16,6 +16,19 @@ use crate::did::Did; use crate::identity::Keypair; use crate::{Error, Result}; +/// Proof chains deeper than this fail closed, in every walk over `prf`: +/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and +/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is +/// longer than owner → agent → node. The bound exists for the hand-built token: +/// the node verifies the chain on the `X-Ucan` header of any signed request, and +/// nothing else says how deep a sender may nest `prf`. In practice not very +/// deep — a proof is a JSON string inside the next link's JSON, so each level +/// re-escapes the one beneath it and the encoding roughly doubles per link +/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should +/// refuse on its own terms rather than count on the encoding running the sender +/// out of room first. +pub const MAX_CHAIN_DEPTH: usize = 8; + /// A UCAN capability: what resource the token grants access to. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Capability { @@ -49,11 +62,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 } } @@ -71,6 +105,138 @@ pub mod caps { pub const REPO_ADMIN: &str = "repo/admin"; } +/// Rules shared by every boundary that decides whether a UCAN can push. +/// +/// Four places answer "can this token push to this repository?": `gl ucan +/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan +/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation, +/// and the node when it authorizes the push. For three review rounds each of them +/// carried its own copy of the answer, and every round found a token that one +/// boundary accepted and the next refused: a constrained grant that imported and +/// then authorized nothing; a wildcard proof the helper narrowed and the node +/// rejected; a resource whose owner the leaf named but the chain root did not. +/// +/// The rules live here, once, in the crate all four already depend on. A boundary +/// may add checks of its own — the node anchors the chain root to a repository +/// record it holds independently, import binds the audience to the local key — +/// but the definition of a *usable push capability* is not one of them. +pub mod push { + use super::{caps, Capability, Ucan}; + + /// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are + /// the same identity. Mirror rows store the bare form; canonical rows and + /// every token store the full form. + /// + /// Collapses representation only within `did:key`. `did:web` and + /// `did:gitlawb` share the base58 space, so a trailing-segment compare would + /// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix, + /// a value that still contains `:` is a non-key DID and matches nothing bare. + pub fn did_key_eq(a: &str, b: &str) -> bool { + if a == b { + return true; + } + fn key_id(d: &str) -> &str { + d.strip_prefix("did:key:").unwrap_or(d) + } + let (ka, kb) = (key_id(a), key_id(b)); + !ka.contains(':') && !kb.contains(':') && ka == kb + } + + /// The actions that authorize a push: `git/push` itself, the action wildcard, + /// and `repo/admin`, which covers it. + pub fn is_push_action(can: &str) -> bool { + can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN + } + + /// `gitlawb://repos//` → `(owner, repo)`, or `None` for any other + /// shape. Exactly two non-empty segments: an owner DID never contains `/`, so + /// a third segment, a trailing slash, or an empty half is malformed rather than + /// something to interpret. + pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> { + let rest = with.strip_prefix("gitlawb://repos/")?; + let (owner, repo) = rest.split_once('/')?; + if owner.is_empty() || repo.is_empty() || repo.contains('/') { + return None; + } + Some((owner, repo)) + } + + /// A push-class capability whose resource is the wildcard. Refused at + /// issuance, at import, by the helper, and by the node: a delegation's scope is + /// fixed when it is issued, and `*` cannot say which repositories it covered + /// at that moment. + pub fn is_push_wildcard(with: &str, can: &str) -> bool { + with == "*" && is_push_action(can) + } + + pub use super::MAX_CHAIN_DEPTH; + + impl Capability { + /// Push-class, unconstrained, and naming exactly this repository. + /// + /// Unconstrained because `nb` has no semantics yet: an owner who wrote + /// constraints meant to restrict, and honouring the capability while + /// ignoring them would grant more than was intended. The owner segment is + /// compared with [`did_key_eq`], so a token issued against the full DID + /// matches a mirror row keyed on the bare form. + pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool { + self.constraints.is_none() + && is_push_action(&self.can) + && parse_repo_resource(&self.with) + .is_some_and(|(o, r)| did_key_eq(o, owner) && r == repo) + } + } + + impl Ucan { + /// Whether this chain — the leaf **and every proof behind it** — carries a + /// capability that [`Capability::grants_push_to`] this repository. + /// + /// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts + /// a concrete child under a `*` parent, so a leaf that names the repository + /// can sit on a proof that names every repository the root owns — including + /// ones created after the delegation was issued. Checking the leaf alone + /// let exactly that through. + /// + /// This establishes scope, not trust. It does not verify signatures, + /// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the + /// root it returns to something held independently of the token. + /// + /// Fails closed on anything it cannot vouch for: a proof that does not + /// decode, more than one proof per link, or a chain deeper than + /// [`MAX_CHAIN_DEPTH`]. + pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool { + self.chain_grants_push_to_at(owner, repo, 0) + } + + fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool { + if depth >= MAX_CHAIN_DEPTH { + return false; + } + if !self + .payload + .att + .iter() + .any(|c| c.grants_push_to(owner, repo)) + { + return false; + } + // `verify_chain` refuses more than one proof per link for the same + // reason: two proofs mean two roots, and nothing says which authorized + // what. + if self.payload.prf.len() > 1 { + return false; + } + match self.payload.prf.first() { + None => true, + Some(token) => match Ucan::decode(token) { + Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1), + Err(_) => false, + }, + } + } + } +} + /// The UCAN payload (what gets signed). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UcanPayload { @@ -146,6 +312,37 @@ 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. + /// + /// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`. + pub fn chain_lifetime_is_bounded(&self) -> bool { + self.chain_lifetime_is_bounded_at(0) + } + + fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool { + if depth >= MAX_CHAIN_DEPTH { + return false; + } + 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_at(depth + 1)) + }) + } + /// 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 +445,33 @@ 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. + /// + /// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node + /// runs this on the `X-Ucan` header of any signed request before it asks + /// what the chain grants, so this is the first place sender-chosen depth is + /// felt: each link costs a signature check and a decode of everything + /// beneath it. The scope walk was bounded and this one was not, which made + /// that bound decorative — a deep chain met the unbounded walk first. + pub fn verify_chain(&self) -> Result { + self.verify_chain_at(0) + } + + fn verify_chain_at(&self, depth: usize) -> Result { + if depth >= MAX_CHAIN_DEPTH { + return Err(Error::Ucan(format!( + "proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted" + ))); + } + // First verify our own signature self.verify_signature()?; @@ -261,34 +483,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()); + }; + + 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 { + // 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_at(depth + 1) } } @@ -664,4 +896,484 @@ 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}" + ); + } +} + +#[cfg(test)] +mod push_scope_tests { + use super::push::*; + use super::{caps, Capability, Ucan}; + use crate::identity::Keypair; + + fn hour() -> chrono::DateTime { + chrono::Utc::now() + chrono::Duration::hours(1) + } + + #[test] + fn did_key_eq_collapses_representation_only_within_did_key() { + assert!(did_key_eq("did:key:z6MkAbc", "z6MkAbc")); + assert!(did_key_eq("z6MkAbc", "did:key:z6MkAbc")); + assert!(did_key_eq("did:key:z6MkAbc", "did:key:z6MkAbc")); + assert!(!did_key_eq("did:key:z6MkAbc", "did:key:z6MkXyz")); + // A bare id must never match across methods: `did:gitlawb` shares the + // base58 space with `did:key`. + assert!(!did_key_eq("did:gitlawb:z6MkAbc", "z6MkAbc")); + assert!(!did_key_eq("did:key:z6MkAbc", "did:gitlawb:z6MkAbc")); + assert!(!did_key_eq("did:web:example.com", "example.com")); + } + + #[test] + fn parse_repo_resource_requires_exactly_two_segments() { + assert_eq!( + parse_repo_resource("gitlawb://repos/did:key:z6Mk/r"), + Some(("did:key:z6Mk", "r")) + ); + for bad in [ + "*", + "", + "gitlawb://repos/", + "gitlawb://repos/owner", + "gitlawb://repos/owner/", + "gitlawb://repos//r", + "gitlawb://repos/owner/r/extra", + "https://repos/owner/r", + ] { + assert_eq!(parse_repo_resource(bad), None, "{bad:?}"); + } + } + + #[test] + fn is_push_wildcard_is_the_resource_wildcard_with_a_push_action() { + for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] { + assert!(is_push_wildcard("*", can), "{can}"); + } + assert!(!is_push_wildcard("*", caps::GIT_FETCH)); + assert!(!is_push_wildcard("*", caps::PR_OPEN)); + assert!(!is_push_wildcard("gitlawb://repos/o/r", caps::GIT_PUSH)); + } + + #[test] + fn a_capability_grants_push_only_when_concrete_unconstrained_and_push_class() { + let owner = "did:key:z6MkOwner"; + let ok = Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH); + assert!(ok.grants_push_to(owner, "r")); + // Bare and full owner forms are the same identity. + assert!(ok.grants_push_to("z6MkOwner", "r")); + + assert!(!Capability::new("*", caps::GIT_PUSH).grants_push_to(owner, "r")); + assert!( + !Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_FETCH) + .grants_push_to(owner, "r") + ); + assert!( + !Capability::new("gitlawb://repos/did:key:z6MkOwner/other", caps::GIT_PUSH) + .grants_push_to(owner, "r") + ); + assert!( + !Capability::new("gitlawb://repos/did:key:z6MkOther/r", caps::GIT_PUSH) + .grants_push_to(owner, "r") + ); + assert!( + !Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH) + .with_constraints(serde_json::json!({"max_bytes": 1})) + .grants_push_to(owner, "r") + ); + for can in ["*", caps::REPO_ADMIN] { + assert!( + Capability::new("gitlawb://repos/did:key:z6MkOwner/r", can) + .grants_push_to(owner, "r"), + "{can} covers git/push" + ); + } + } + + /// The chain walk, at every depth: a wildcard anywhere denies; concrete + /// everywhere grants. The grandparent case is the one a single-level check + /// misses, because the immediate proof already names the repo. + #[test] + fn chain_grants_push_only_when_every_link_names_the_repo() { + let owner = Keypair::generate(); + let a = Keypair::generate(); + let b = Keypair::generate(); + let node = Keypair::generate(); + let owner_s = owner.did().to_string(); + let res = format!("gitlawb://repos/{owner_s}/r"); + let concrete = || vec![Capability::new(&res, caps::GIT_PUSH)]; + let wild = || vec![Capability::new("*", caps::GIT_PUSH)]; + + // Single self-issued link. + assert!(Ucan::issue(&owner, a.did(), concrete(), Some(hour())) + .unwrap() + .chain_grants_push_to(&owner_s, "r")); + assert!(!Ucan::issue(&owner, a.did(), wild(), Some(hour())) + .unwrap() + .chain_grants_push_to(&owner_s, "r")); + + // Two links: concrete leaf on a wildcard proof must deny. + let wild_proof = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap(); + let leaf = Ucan::delegate(&a, node.did(), concrete(), Some(hour()), &wild_proof).unwrap(); + assert!( + leaf.verify_chain().is_ok(), + "attenuation accepts it — that is why we walk" + ); + assert!(!leaf.chain_grants_push_to(&owner_s, "r")); + + // Three links: wildcard grandparent, concrete parent, concrete leaf. + let gp = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap(); + let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap(); + let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap(); + assert!(leaf.verify_chain().is_ok()); + assert!( + !leaf.chain_grants_push_to(&owner_s, "r"), + "a wildcard two links up must deny" + ); + + // Three links, all concrete. + let gp = Ucan::issue(&owner, a.did(), concrete(), Some(hour())).unwrap(); + let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap(); + let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap(); + assert!(leaf.chain_grants_push_to(&owner_s, "r")); + // And the bare owner form is the same repo. + assert!(leaf.chain_grants_push_to(owner_s.strip_prefix("did:key:").unwrap(), "r")); + // But not another repo. + assert!(!leaf.chain_grants_push_to(&owner_s, "other")); + } + + /// Fail closed on anything the walk cannot vouch for. + #[test] + fn chain_walk_fails_closed_on_undecodable_or_multi_proofs_or_excess_depth() { + let owner = Keypair::generate(); + let a = Keypair::generate(); + let owner_s = owner.did().to_string(); + let res = format!("gitlawb://repos/{owner_s}/r"); + let cap = || vec![Capability::new(&res, caps::GIT_PUSH)]; + + let mut broken = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap(); + broken.payload.prf = vec!["not a ucan".into()]; + assert!(!broken.chain_grants_push_to(&owner_s, "r")); + + let good = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap(); + let mut two = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap(); + two.payload.prf = vec![good.encode().unwrap(), good.encode().unwrap()]; + assert!(!two.chain_grants_push_to(&owner_s, "r")); + + // Deeper than MAX_CHAIN_DEPTH, all concrete: refused on depth alone. + let mut cur = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap(); + let mut signer = a; + for _ in 0..MAX_CHAIN_DEPTH { + let next = Keypair::generate(); + cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap(); + signer = next; + } + assert!( + !cur.chain_grants_push_to(&owner_s, "r"), + "depth bound must fail closed" + ); + } +} + +/// Every walk over `prf` stops at the same depth. The round-ten P1: the scope +/// walk was bounded while `verify_chain` and `chain_lifetime_is_bounded` were +/// not, and the node runs `verify_chain` on an attacker-supplied `X-Ucan` header +/// before the bounded walk ever sees it, so the bound protected nothing. +#[cfg(test)] +mod chain_depth_tests { + use super::{caps, Capability, Ucan, MAX_CHAIN_DEPTH}; + use crate::identity::Keypair; + + fn hour() -> chrono::DateTime { + chrono::Utc::now() + chrono::Duration::hours(1) + } + + /// A well-formed, fully signed, expiring chain of exactly `links` links, every + /// one naming the owner's repository, so the only thing any walk can object + /// to is how long it is. Returns the leaf and the owner it roots at. + fn concrete_chain(links: usize) -> (Ucan, Keypair) { + assert!(links >= 1); + let owner = Keypair::generate(); + let owner_s = owner.did().to_string(); + let res = format!("gitlawb://repos/{owner_s}/r"); + let cap = || vec![Capability::new(&res, caps::GIT_PUSH)]; + + let mut signer = Keypair::generate(); + let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour())).unwrap(); + for _ in 1..links { + let next = Keypair::generate(); + cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap(); + signer = next; + } + (cur, owner) + } + + #[test] + fn a_chain_at_the_bound_passes_every_walk() { + let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH); + let owner_s = owner.did().to_string(); + assert_eq!( + leaf.verify_chain() + .expect("a chain at the bound must verify"), + owner.did() + ); + assert!(leaf.chain_lifetime_is_bounded()); + assert!(leaf.chain_grants_push_to(&owner_s, "r")); + } + + #[test] + fn one_link_past_the_bound_fails_every_walk_closed() { + let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH + 1); + let owner_s = owner.did().to_string(); + + let err = leaf + .verify_chain() + .expect_err("verify_chain must refuse a chain past the bound"); + assert!( + err.to_string().contains("deeper than"), + "the refusal must say depth was the reason, not a fake signature failure: {err}" + ); + assert!( + !leaf.chain_lifetime_is_bounded(), + "an unwalkable chain cannot be vouched for as bounded" + ); + assert!(!leaf.chain_grants_push_to(&owner_s, "r")); + } + + /// A chain well past the bound is refused from the top, before any per-link + /// work. Only a few links past it, deliberately: a proof is embedded as a JSON + /// string inside the next link's JSON, so every level re-escapes the one + /// inside it and the encoding roughly doubles per link — eight links is about + /// 10 KiB, sixteen is megabytes, and a chain of "a few hundred levels" cannot + /// physically be encoded, let alone sent as a header. The bound is still + /// what stops the reachable dozen-odd levels from each costing a decode of + /// everything beneath them. + #[test] + fn a_chain_well_past_the_bound_is_refused_not_walked() { + let (leaf, _) = concrete_chain(MAX_CHAIN_DEPTH + 4); + let err = leaf.verify_chain().expect_err("must refuse"); + assert!(err.to_string().contains("deeper than"), "{err}"); + assert!(!leaf.chain_lifetime_is_bounded()); + } } diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index df10175a..55c0cf3c 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -68,16 +68,9 @@ pub(crate) async fn authorize_repo_read( /// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a /// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal. pub(crate) fn did_matches(a: &str, b: &str) -> bool { - if a == b { - return true; - } - fn key_id(d: &str) -> &str { - d.strip_prefix("did:key:").unwrap_or(d) - } - let (ka, kb) = (key_id(a), key_id(b)); - // After stripping `did:key:`, a value still containing ':' is a non-key full - // DID — do not let it match a bare `did:key` id. - !ka.contains(':') && !kb.contains(':') && ka == kb + // One implementation, shared with `gl` and `git-remote-gitlawb` through core: + // every boundary that compares a DID applies the same collapse rule. + gitlawb_core::ucan::push::did_key_eq(a, b) } /// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 4e327c42..22e275b4 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1861,14 +1861,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(), )), @@ -2008,6 +2015,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, @@ -2055,6 +2066,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, @@ -2066,9 +2078,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 @@ -3623,7 +3643,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] @@ -3631,36 +3651,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) ── @@ -5940,6 +6016,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"), @@ -5958,6 +6035,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"), @@ -6064,6 +6142,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"), @@ -6151,6 +6230,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"), @@ -6308,6 +6388,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(Some( "203.0.113.81:5000".parse::().unwrap(), )), @@ -6462,6 +6543,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(), )), + None, crate::rate_limit::PeerAddr(Some( "203.0.113.83:5000".parse::().unwrap(), )), @@ -6552,6 +6634,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(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -6570,6 +6653,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.72:5000".parse().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7218,6 +7302,7 @@ mod tests { State(state), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), + None, crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body(new_sha), @@ -7312,6 +7397,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6f4fast".to_string(), )), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7372,6 +7458,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6f4park".to_string(), )), + None, crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), ref_update_body("2222222222222222222222222222222222222222"), @@ -8597,6 +8684,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"), @@ -8625,6 +8713,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(), )), @@ -8721,6 +8810,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"), @@ -8814,6 +8904,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"), @@ -8841,6 +8932,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(), )), @@ -9025,6 +9117,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"), @@ -9098,6 +9191,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6ovflow".to_string(), )), + None, crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body("1111111111111111111111111111111111111111"), @@ -9152,6 +9246,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"), @@ -9257,6 +9352,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"), @@ -9347,6 +9443,7 @@ mod tests { State(st), Path((owner.to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), + None, crate::rate_limit::PeerAddr(Some(edge)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -9440,6 +9537,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"), @@ -9500,6 +9598,7 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6f1none".to_string(), )), + None, crate::rate_limit::PeerAddr(None), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -9544,6 +9643,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"), @@ -10054,6 +10154,7 @@ mod tests { State(state.clone()), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), + 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 27b67786..8db3f9f6 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,16 +17,74 @@ 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 a verified UCAN authorizes a push to `record`. +/// +/// Three conditions, all 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. Every link is bounded. There is no revocation path, so an unbounded link +/// is a permanent grant. +/// 3. Every link — the leaf and every proof behind it — carries an +/// unconstrained push-class capability naming this repository. That is +/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule +/// `gl ucan import` applies before storing a token and `git-remote-gitlawb` +/// applies before minting an invocation, so what one boundary accepts the +/// next does not refuse. +/// +/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did +/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and +/// that narrowing was exactly what the helper performed, so one owner-issued +/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the +/// owner had — or created later — and both the chain check and the owner-root +/// check passed. A delegation's scope is fixed when it is issued. +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; + } + verified + .ucan + .chain_grants_push_to(&record.owner_did, &record.name) } use gitlawb_core::http_sig::{ @@ -270,7 +328,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 +356,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 +416,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, root = %verified.root, "UCAN chain validated"); - tracing::debug!(did = %signer_did, "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 +463,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, @@ -637,3 +733,407 @@ 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 + } + )); + } + + /// A `*` two links up. The immediate proof names the repository, so a walk that + /// checks only one level of `prf` stays green here — this is the case that + /// makes the recursion load-bearing rather than incidental. + #[test] + fn a_wildcard_grandparent_cannot_reach_the_repo_through_a_concrete_parent() { + let owner = Keypair::generate(); + let intermediary = 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 grandparent = Ucan::issue( + &owner, + intermediary.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + Some(hour), + ) + .unwrap(); + let parent = Ucan::delegate( + &intermediary, + agent.did(), + vec![Capability::new(&resource, caps::GIT_PUSH)], + Some(hour), + &grandparent, + ) + .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"); + assert_eq!(root, owner.did()); + let rec = repo(&owner.did().to_string(), "myrepo"); + assert!( + !ucan_grants_push( + &rec, + &VerifiedUcan { + ucan: invocation, + root + } + ), + "a wildcard anywhere in the chain must deny, not just in the immediate proof" + ); + } + + /// And the same three-link shape with every link concrete must still grant, so + /// the walk is refusing the wildcard and not the depth. + #[test] + fn a_three_link_concrete_chain_still_authorizes() { + let owner = Keypair::generate(); + let intermediary = 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 cap = || vec![Capability::new(&resource, caps::GIT_PUSH)]; + + let grandparent = Ucan::issue(&owner, intermediary.did(), cap(), Some(hour)).unwrap(); + let parent = + Ucan::delegate(&intermediary, agent.did(), cap(), Some(hour), &grandparent).unwrap(); + let invocation = Ucan::delegate(&agent, node.did(), cap(), Some(hour), &parent).unwrap(); + + let root = invocation.verify_chain().unwrap(); + let rec = repo(&owner.did().to_string(), "myrepo"); + assert!(ucan_grants_push( + &rec, + &VerifiedUcan { + ucan: invocation, + root + } + )); + } + + /// The round-ten P1, pinned where it bites. The middleware verifies the chain + /// on the `X-Ucan` header of any signed request before the scope walk runs, + /// so a depth bound that lived only in `chain_grants_push_to` protected + /// nothing: a hand-built header reached the unbounded recursion first. A + /// chain at `MAX_CHAIN_DEPTH` links validates; one link more is refused here, + /// with 401 and a message that names depth, before any authorization runs. + #[test] + fn validate_ucan_chain_stops_at_the_depth_bound() { + use gitlawb_core::ucan::MAX_CHAIN_DEPTH; + + let owner = Keypair::generate(); + let node = Keypair::generate(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let resource = format!("gitlawb://repos/{}/myrepo", owner.did()); + let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)]; + + // owner -> k1 -> ... -> signer: MAX_CHAIN_DEPTH - 1 links, all concrete, + // all signed, all expiring. Only the length is in question. + let mut signer = Keypair::generate(); + let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour)).unwrap(); + for _ in 2..MAX_CHAIN_DEPTH { + let next = Keypair::generate(); + cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap(); + signer = next; + } + + // Wrapped into an invocation for the node: exactly MAX_CHAIN_DEPTH links. + let at_bound = Ucan::delegate(&signer, node.did(), cap(), Some(hour), &cur).unwrap(); + let verified = validate_ucan_chain(&at_bound.encode().unwrap(), &node.did(), &signer.did()) + .expect("a chain at the bound must still validate"); + assert_eq!(verified.root, owner.did()); + + // One more link and it is past the bound. + let next = Keypair::generate(); + let deeper = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap(); + let past_bound = Ucan::delegate(&next, node.did(), cap(), Some(hour), &deeper).unwrap(); + let (status, body) = + match validate_ucan_chain(&past_bound.encode().unwrap(), &node.did(), &next.did()) { + Ok(_) => panic!("a chain past the bound must be refused, not walked"), + Err(refusal) => refusal, + }; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let message = body.0["message"].as_str().unwrap_or_default(); + assert!( + message.contains("deeper than"), + "the refusal must name depth as the reason: {message}" + ); + } + + #[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 430c0600..eb4077ef 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -2390,6 +2390,456 @@ mod tests { authorization" ); } + /// 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" + ); + } + + /// The two-gate contract on a PRIVATE repository, end to end. + /// + /// A `git push` crosses two independent gates. Gate 1 is read visibility on + /// the `info/refs` advertisement (both services): the owner, anyone on a public + /// repository, or a caller a rule names in `reader_dids`. Gate 2 is push + /// authorization on the `git-receive-pack` POST, the owner gate a delegation + /// clears. A UCAN speaks only to gate 2 and grants no read, so a delegate on a + /// private repository needs BOTH a push delegation AND a reader rule. That + /// composition — not a POST defect — is what "delegated push does not work on + /// my private repo" turns out to be; the sibling test above runs on a public + /// repository and never meets gate 1. + /// + /// Status codes discriminate as in the sibling: 404 is gate 1 withholding the + /// advertisement, 403 is gate 2 refusing the push, 500 is a request that cleared + /// every gate and reached git on a repo with no disk backing. + /// + /// Each gate is mounted under the middleware production gives it (`server.rs`): + /// `optional_signature` alone on `info/refs`, `require_signature` plus + /// `require_ucan_chain` on the receive-pack POST. + #[sqlx::test] + async fn delegated_push_on_a_private_repo_needs_read_visibility_too(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; + let mut cfg = (*state.config).clone(); + cfg.enforce_owner_push = true; + state.config = Arc::new(cfg); + + let mut repo = seed_repo(&owner_did, "priv-deleg"); + repo.is_public = false; + state + .db + .create_repo(&repo) + .await + .expect("seed private repo"); + + let advertisement = || { + Router::new() + .route( + "/{owner}/{repo}/info/refs", + axum::routing::get(crate::api::repos::git_info_refs), + ) + .layer(axum::middleware::from_fn(crate::auth::optional_signature)) + .with_state(state.clone()) + }; + let receive_pack = || { + 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()) + }; + + // A delegation that is valid in every respect the push gate checks. + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let delegation = Ucan::issue( + &owner, + agent.did(), + vec![Capability::new( + format!("gitlawb://repos/{owner_did}/priv-deleg"), + 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 invocation") + .encode() + .expect("encode invocation"); + + let advert_path = format!("/{short}/priv-deleg.git/info/refs?service=git-receive-pack"); + let signed_advert = |ucan: Option<&str>| { + let signed = sign_request(&agent, "GET", &advert_path, b""); + let mut req = Request::builder() + .method(Method::GET) + .uri(&advert_path) + .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::empty()).expect("request") + }; + let push_path = format!("/{short}/priv-deleg.git/git-receive-pack"); + let push_body = b"0000".to_vec(); + let signed_push = |ucan: Option<&str>| { + let signed = sign_request(&agent, "POST", &push_path, &push_body); + let mut req = Request::builder() + .method(Method::POST) + .uri(&push_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(push_body.clone())).expect("request") + }; + + // 1. Delegation, no reader rule. Gate 1 withholds the advertisement even + // though the delegate presents the token there too: a UCAN grants no read. + let resp = advertisement() + .oneshot(signed_advert(Some(&invocation))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "a push delegation must not open the advertisement of a private repo" + ); + // Gate 2, taken on its own, is satisfied by the same delegation: the two + // gates are independent, which is exactly why both must be arranged. + let resp = receive_pack() + .oneshot(signed_push(Some(&invocation))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "the push gate must clear on the delegation alone and reach git" + ); + + // 2. The owner makes the agent a reader of the whole repository — what + // `gl visibility set / --repo --readers ` does. + state + .db + .set_visibility_rule( + &repo.id, + "/", + crate::db::VisibilityMode::B, + &[agent.did().to_string()], + &owner_did, + ) + .await + .expect("reader rule"); + let resp = advertisement() + .oneshot(signed_advert(Some(&invocation))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "a reader clears the advertisement gate and reaches git" + ); + let resp = receive_pack() + .oneshot(signed_push(Some(&invocation))) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "with both gates arranged the push reaches git" + ); + + // 3. Reader rule, no delegation: reading is not pushing. + let resp = advertisement().oneshot(signed_advert(None)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "the reader rule alone still opens the advertisement" + ); + let resp = receive_pack().oneshot(signed_push(None)).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "a reader with no delegation must still be refused on the 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 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/http.rs b/crates/gl/src/http.rs index 7a28c6f7..baba08ad 100644 --- a/crates/gl/src/http.rs +++ b/crates/gl/src/http.rs @@ -323,8 +323,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> /// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which /// `char::is_control` does not cover — they can reorder the displayed line). pub(crate) fn sanitize_node_msg(s: &str) -> String { - s.chars() - .filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c)) + gitlawb_core::sanitize::strip_terminal_controls(s) + .chars() .take(200) .collect() } 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 93ca5511..1d600ddd 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -24,7 +24,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..86581634 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": {} } }, { @@ -560,7 +560,7 @@ fn tool_definitions() -> Value { // ── UCAN delegation tools ─────────────────────────────────────────── { "name": "ucan_delegate", - "description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.", + "description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import ` or the `ucan_import` tool so their `git push` can present it.", "inputSchema": { "type": "object", "required": ["to", "resource", "action"], @@ -568,13 +568,13 @@ fn tool_definitions() -> Value { "to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" }, "resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" }, "action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" }, - "expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" } + "expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." } } } }, { "name": "ucan_verify", - "description": "Verify a UCAN token's signature and expiry. Returns structured verification result.", + "description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.", "inputSchema": { "type": "object", "required": ["token"], @@ -583,6 +583,17 @@ fn tool_definitions() -> Value { } } }, + { + "name": "ucan_import", + "description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.", + "inputSchema": { + "type": "object", + "required": ["token"], + "properties": { + "token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" } + } + } + }, // ── Issue tools ───────────────────────────────────────────────────── { @@ -759,15 +770,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" => { @@ -1125,14 +1166,35 @@ async fn call_tool( let resource = args["resource"].as_str().context("missing 'resource'")?; let action = args["action"].as_str().context("missing 'action'")?; + // The same refusal `gl ucan delegate` applies. A push-class wildcard cannot + // be honoured anywhere downstream — import refuses it, the helper refuses + // it, the node refuses it — so issuing one only manufactures a token that + // fails later with less context than this. + if gitlawb_core::ucan::push::is_push_wildcard(resource, action) { + anyhow::bail!( + "a wildcard resource cannot carry a push capability: a delegation is \n scoped to the repositories it names when issued, and `*` cannot say \n which those were. Use resource gitlawb://repos//." + ); + } + let audience: gitlawb_core::did::Did = to_str .parse() .map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?; - let exp = args - .get("expiry_hours") - .and_then(|v| v.as_i64()) - .map(|h| chrono::Utc::now() + chrono::Duration::hours(h)); + // The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way + // to issue an unbounded token. An omitted field here used to mean "no + // expiry", which for a push capability is dead on arrival: import + // refuses it and so does the node (`chain_lifetime_is_bounded`). Same + // default, same rule. + let hours = match args.get("expiry_hours") { + None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64, + Some(v) => v + .as_i64() + .context("invalid expiry_hours: expected an integer")?, + }; + if hours < 1 { + anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing"); + } + let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours)); let ucan = gitlawb_core::ucan::Ucan::issue( &kp, @@ -1155,23 +1217,26 @@ async fn call_tool( let token = args["token"].as_str().context("missing 'token'")?; let ucan = gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?; - let sig_valid = ucan.verify_signature().is_ok(); - let expired = ucan.is_expired(); - let caps: Vec = ucan - .payload - .att - .iter() - .map(|c| json!({ "with": c.with, "can": c.can })) - .collect(); + // The same report `gl ucan verify` prints, so the two cannot disagree + // about what "valid" means. In particular the proof chain is walked: + // a leaf with a good signature on a broken chain used to report valid + // here and then fail at import and at the node. + let report = crate::ucan_cmd::VerifyReport::of(&ucan); + Ok(serde_json::to_string_pretty(&report.to_json())?) + } + "ucan_import" => { + let token = args["token"].as_str().context("missing 'token'")?; + // `gl ucan import` with a JSON answer: one set of checks, one store, so + // an MCP-only agent can complete the delegated-push workflow without + // a shell and without a second copy of the rules. + let imported = crate::ucan_cmd::import_delegation(token, dir).await?; Ok(serde_json::to_string_pretty(&json!({ - "valid": sig_valid && !expired, - "signature_valid": sig_valid, - "expired": expired, - "issuer": ucan.payload.iss.to_string(), - "audience": ucan.payload.aud.to_string(), - "capabilities": caps, - "expires": ucan.payload.exp, + "owner": imported.owner, + "repo": imported.repo, + "root_issuer": imported.root, + "expires": imported.expires, + "path": imported.path.display().to_string(), }))?) } @@ -1564,6 +1629,7 @@ mod tests { .collect(); assert!(names.contains(&"ucan_delegate")); assert!(names.contains(&"ucan_verify")); + assert!(names.contains(&"ucan_import")); } #[test] @@ -1730,6 +1796,38 @@ mod tests { assert_eq!(parsed["capability"]["can"], "git/push"); } + /// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named + /// resource, so it could never catch the MCP arm issuing a wildcard the CLI + /// refuses. + #[tokio::test] + async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() { + let dir = tempfile::TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + std::fs::write( + dir.path().join("identity.pem"), + kp.to_pem().unwrap().as_bytes(), + ) + .unwrap(); + let audience = gitlawb_core::identity::Keypair::generate(); + + for action in ["git/push", "*", "repo/admin"] { + let err = call_tool( + "ucan_delegate", + json!({ + "to": audience.did().to_string(), + "resource": "*", + "action": action, + "expiry_hours": 24, + }), + "http://localhost", + Some(dir.path()), + ) + .await + .expect_err("MCP must refuse a push-class wildcard like the CLI does"); + assert!(err.to_string().contains("wildcard"), "{action}: {err}"); + } + } + #[tokio::test] async fn test_ucan_verify_via_mcp() { let kp = gitlawb_core::identity::Keypair::generate(); @@ -1758,6 +1856,204 @@ mod tests { assert_eq!(parsed["valid"], true); assert_eq!(parsed["signature_valid"], true); assert_eq!(parsed["expired"], false); + assert_eq!(parsed["chain_valid"], true); + assert_eq!(parsed["root_issuer"], kp.did().to_string()); + } + + /// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a + /// good signature sitting on a proof that never named its issuer reported + /// `valid: true` here, and was then refused by import and by the node. + #[tokio::test] + async fn test_ucan_verify_via_mcp_walks_the_proof_chain() { + let alice = gitlawb_core::identity::Keypair::generate(); + let bob = gitlawb_core::identity::Keypair::generate(); + let eve = gitlawb_core::identity::Keypair::generate(); + let node = gitlawb_core::identity::Keypair::generate(); + let cap = || { + vec![gitlawb_core::ucan::Capability::new( + "gitlawb://repos/alice/r", + "git/push", + )] + }; + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + + // Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's + // own signature is fine — the break is in the chain. + let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap(); + let forged = + gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap(); + assert!( + forged.verify_signature().is_ok(), + "fixture: the leaf signature is valid" + ); + + let result = call_tool( + "ucan_verify", + json!({"token": forged.encode().unwrap()}), + "http://localhost", + None, + ) + .await + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["signature_valid"], true); + assert_eq!(parsed["expired"], false); + assert_eq!(parsed["chain_valid"], false); + assert_eq!( + parsed["valid"], false, + "a broken proof chain must not report valid: {parsed}" + ); + assert!( + parsed["chain_error"] + .as_str() + .unwrap_or_default() + .contains("proof chain broken"), + "the reason must be reported: {parsed}" + ); + assert!(parsed["root_issuer"].is_null()); + } + + /// The workflow an MCP-only agent could not finish: it could be issued a + /// delegation and verify it, but not store it where `git-remote-gitlawb` + /// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same + /// file — so a token the CLI would store lands in the same place, and one the + /// CLI would refuse is refused with the same reason. + #[tokio::test] + async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() { + let dir = tempfile::TempDir::new().unwrap(); + let me = gitlawb_core::identity::Keypair::generate(); + std::fs::write( + dir.path().join("identity.pem"), + me.to_pem().unwrap().as_bytes(), + ) + .unwrap(); + let owner = gitlawb_core::identity::Keypair::generate(); + let owner_did = owner.did().to_string(); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + let token = gitlawb_core::ucan::Ucan::issue( + &owner, + me.did(), + vec![gitlawb_core::ucan::Capability::new( + format!("gitlawb://repos/{owner_did}/ci-repo"), + "git/push", + )], + Some(hour), + ) + .unwrap() + .encode() + .unwrap(); + + let result = call_tool( + "ucan_import", + json!({"token": token}), + "http://localhost", + Some(dir.path()), + ) + .await + .expect("a delegation addressed to this identity must import"); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["owner"], owner_did); + assert_eq!(parsed["repo"], "ci-repo"); + assert_eq!(parsed["root_issuer"], owner_did); + assert_eq!(parsed["expires"], hour.timestamp()); + + let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo"); + assert_eq!(parsed["path"], stored.display().to_string()); + assert_eq!( + std::fs::read_to_string(&stored).unwrap(), + token, + "the token must be stored byte-for-byte where the helper reads it" + ); + + // Same refusals as the CLI: a token addressed to someone else is unusable + // here however well-formed it is. + let stranger = gitlawb_core::identity::Keypair::generate(); + let misaddressed = gitlawb_core::ucan::Ucan::issue( + &owner, + stranger.did(), + vec![gitlawb_core::ucan::Capability::new( + format!("gitlawb://repos/{owner_did}/ci-repo"), + "git/push", + )], + Some(hour), + ) + .unwrap() + .encode() + .unwrap(); + let err = call_tool( + "ucan_import", + json!({"token": misaddressed}), + "http://localhost", + Some(dir.path()), + ) + .await + .expect_err("a token addressed to another identity must be refused"); + assert!(err.to_string().contains("addressed to"), "{err}"); + } + + /// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which + /// import and the node both refuse for push. The default is now the CLI's. + #[tokio::test] + async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() { + let dir = tempfile::TempDir::new().unwrap(); + let kp = gitlawb_core::identity::Keypair::generate(); + std::fs::write( + dir.path().join("identity.pem"), + kp.to_pem().unwrap().as_bytes(), + ) + .unwrap(); + let audience = gitlawb_core::identity::Keypair::generate(); + + let before = chrono::Utc::now(); + let result = call_tool( + "ucan_delegate", + json!({ + "to": audience.did().to_string(), + "resource": "gitlawb://repos/test/repo", + "action": "git/push", + }), + "http://localhost", + Some(dir.path()), + ) + .await + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + + let expires = parsed["expires"] + .as_i64() + .expect("an omitted expiry_hours must still produce an expiring token"); + let expected = (before + + chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64)) + .timestamp(); + assert!( + (expires - expected).abs() <= 60, + "expiry must default to the CLI's {} hours, got {expires} vs {expected}", + crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS + ); + let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap(); + assert!( + token.chain_lifetime_is_bounded(), + "the token must satisfy the rule import and the node apply" + ); + + // A token that has already expired grants nothing, so the request is a + // mistake to report, not a token to mint. + for bad in [json!(0), json!(-5)] { + let err = call_tool( + "ucan_delegate", + json!({ + "to": audience.did().to_string(), + "resource": "gitlawb://repos/test/repo", + "action": "git/push", + "expiry_hours": bad, + }), + "http://localhost", + Some(dir.path()), + ) + .await + .expect_err("a non-positive expiry must be refused"); + assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}"); + } } #[tokio::test] @@ -2027,9 +2323,95 @@ mod tests { } #[test] - fn test_tool_count_is_42() { + fn test_tool_count_matches_the_shipped_list() { let tools = tool_definitions(); let count = tools.as_array().unwrap().len(); - assert_eq!(count, 40, "expected 40 tools, got {count}"); + // 41 with `ucan_import`. Bump this when a tool is added or removed; the + // point is that no tool is added without someone noticing. + assert_eq!(count, 41, "expected 41 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..c0f58ccd 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, @@ -48,13 +56,78 @@ pub enum UcanCmd { #[arg(long)] dir: Option, }, - /// Verify a UCAN token (from stdin, file, or argument) + /// Verify a UCAN token: signature, expiry, and its proof chain back to the root Verify { /// 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,536 @@ 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, + } +} + +/// Largest token file `gl ucan import` and `gl ucan verify` will read. A full +/// eight-link chain encodes to well under 32 KiB even though every nesting level +/// re-escapes the one inside it, so this only stops a mistaken argument from +/// slurping something enormous into memory. +const MAX_TOKEN_FILE_BYTES: u64 = 1 << 20; + +/// The token a command was given: the argument itself when it is JSON, otherwise +/// the contents of the file it names. +/// +/// A path that exists but cannot be read — a directory, a permission problem, a +/// file past the size cap — is reported as that. It used to fall through to +/// "treat the argument as a token", which then failed in `Ucan::decode` with a +/// message about the path string not being JSON, sending the reader after the +/// wrong problem. +fn read_token_argument(arg: &str) -> Result { + let trimmed = arg.trim(); + if trimmed.starts_with('{') { + return Ok(trimmed.to_string()); + } + let meta = match std::fs::metadata(arg) { + Ok(meta) => meta, + // Not a path at all: a token in some form `decode` may or may not accept, + // whose error will say so. `InvalidFilename` is what a long JSON string + // produces on Windows (and past PATH_MAX elsewhere), `InvalidInput` its + // older spelling. + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::InvalidFilename + | std::io::ErrorKind::InvalidInput + ) => + { + return Ok(trimmed.to_string()); + } + Err(e) => return Err(e).with_context(|| format!("cannot read token file {arg}")), + }; + if !meta.is_file() { + anyhow::bail!("{arg} is not a file"); + } + if meta.len() > MAX_TOKEN_FILE_BYTES { + anyhow::bail!( + "{arg} is {} bytes, larger than any UCAN token (the cap is {MAX_TOKEN_FILE_BYTES})", + meta.len() + ); } + let contents = + std::fs::read_to_string(arg).with_context(|| format!("cannot read token file {arg}"))?; + Ok(contents.trim().to_string()) +} + +/// Most characters of one token-derived value that reach the terminal. +const SHOWN_MAX_CHARS: usize = 512; + +/// Token-derived text, made safe for a terminal. +/// +/// Every string a token carries — `with`, `can`, `iss`, `aud`, and the error text +/// `verify_chain` builds out of them — is chosen by whoever built the token: `Did` +/// deserializes any string, and a capability field is free text. Printed raw, an +/// ANSI or OSC sequence in one of them drives the operator's terminal (INV-6). +/// The strip is the workspace's one definition; the cap keeps a megabyte of +/// nonsense from scrolling the reason off the screen. +fn shown(s: &str) -> String { + let clean = gitlawb_core::sanitize::strip_terminal_controls(s); + if clean.chars().count() > SHOWN_MAX_CHARS { + let mut cut: String = clean.chars().take(SHOWN_MAX_CHARS).collect(); + cut.push('…'); + cut + } else { + clean + } +} + +/// What a successful import stored, for the caller to report. +pub(crate) struct Imported { + pub owner: String, + pub repo: String, + pub path: PathBuf, + /// The root issuer the chain verified to — the owner the node will anchor on. + pub root: String, + pub expires: Option, +} + +/// 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`. `gl ucan import` and the MCP `ucan_import` +/// tool are both this function: one set of checks, one store. +pub(crate) async fn import_delegation( + token: &str, + dir: Option<&std::path::Path>, +) -> Result { + let raw = read_token_argument(token)?; + + 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) + .context("cannot tell who this delegation is for without a local identity")?; + let my_did = me.did().to_string(); + if !gitlawb_core::ucan::push::did_key_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}`.", + shown(&ucan.payload.aud.to_string()) + ); + } + + // 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}"); + + // Every capability is admitted by the SAME rule the helper and the node apply — + // `gitlawb_core::ucan::push`. Three independent definitions of "usable for push" + // is how a token got accepted at one stage and refused at the next: a + // constrained grant that imported and then authorized nothing; a wildcard proof + // the helper narrowed and the node rejected; a resource whose owner the leaf + // named but the chain root did not. + // + // Two things are checked here that `verify_chain` does not establish: + // + // - The owner named by each capability is the VERIFIED ROOT. `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 naming `gitlawb://repos//repo` and displace the + // working delegation for a repository they have no authority over. + // + // - EVERY LINK names the repository, not just the leaf. `is_attenuated_by` + // accepts a concrete child under a `*` parent, so a leaf that names the + // repository can sit on a proof that names every repository the root owns. + // The node walks the whole chain and refuses that; storing it here would + // turn a successful import into a guaranteed 403 at push time — the silent + // success this command exists to prevent. + // + // Everything is validated before anything is written, so a later bad capability + // cannot leave a multi-repository import half applied. + let mut push_caps: Vec<(String, String)> = Vec::new(); + let mut rejected_owner: Vec = Vec::new(); + let mut rejected_chain: Vec = Vec::new(); + for cap in &ucan.payload.att { + if !gitlawb_core::ucan::push::is_push_action(&cap.can) || cap.constraints.is_some() { + continue; + } + // `repo_from_resource` is the shared structural parse plus a filename + // allow-list: this value becomes a path the store WRITES to. + let Some((owner, repo)) = repo_from_resource(&cap.with) else { + continue; + }; + if !gitlawb_core::ucan::push::did_key_eq(&owner, &root.to_string()) { + rejected_owner.push(cap.with.clone()); + continue; + } + if !ucan.chain_grants_push_to(&owner, &repo) { + rejected_chain.push(cap.with.clone()); + continue; + } + push_caps.push((owner, repo)); + } + + if !rejected_owner.is_empty() { + anyhow::bail!( + "this delegation names repositories owned by someone other than the \ + chain's root issuer ({}): {}\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.", + shown(&root.to_string()), + rejected_owner + .iter() + .map(|s| shown(s)) + .collect::>() + .join(", ") + ); + } + + if !rejected_chain.is_empty() { + anyhow::bail!( + "this delegation's leaf names {}, but a proof behind it does not — it is a \ + wildcard, is constrained, or names another repository. A delegation's \ + scope is fixed when it is issued, and the node refuses a chain whose \ + proofs are wider than its leaf, so storing this would only defer the \ + refusal to `git push`. Ask the owner to issue the delegation directly \ + against this repository.", + rejected_chain + .iter() + .map(|s| shown(s)) + .collect::>() + .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!( + "{} -> {}{}", + shown(&c.with), + shown(&c.can), + if c.constraints.is_some() { + " (constrained)" + } else { + "" + } + )) + .collect::>() + .join(", ") + ); + } + + // One repository per token. `gl ucan delegate` and the MCP tool issue one + // capability per token, so anything naming several repositories was built by + // hand. The store publishes one file per repository and `write_private_file` + // is atomic per file, not across files: a failure on the second write would + // leave the first published while the command reports failure. Refusing is + // honest where a half-applied import is not. Several capabilities on the SAME + // repository (`git/push` plus `repo/admin`, say) are one file and are fine. + push_caps.sort(); + push_caps.dedup(); + if push_caps.len() > 1 { + anyhow::bail!( + "this delegation names {} repositories ({}), and `gl ucan import` stores one \ + repository per token. Ask the owner for one delegation per repository.", + push_caps.len(), + push_caps + .iter() + .map(|(owner, repo)| format!("{owner}/{repo}")) + .collect::>() + .join(", ") + ); + } + + let (owner, repo) = push_caps + .into_iter() + .next() + .expect("push_caps is non-empty: the empty case bailed above"); + + // 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.map(std::path::Path::to_path_buf))?; + let store = base.join("delegations"); + create_private_dir(&store).with_context(|| format!("could not create {}", store.display()))?; + + 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()))?; + + Ok(Imported { + owner, + repo, + path, + root: root.to_string(), + expires: ucan.payload.exp, + }) +} + +async fn cmd_import(token: String, dir: Option) -> Result<()> { + let imported = import_delegation(&token, dir.as_deref()).await?; + // `owner` and `repo` passed `is_safe_component`, so they are plain to print. + println!( + "Stored delegation for {}/{} at {}", + imported.owner, + imported.repo, + imported.path.display() + ); + Ok(()) +} + +/// 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)) +} + +/// Test-only fault injection for the staging write. +/// +/// The refresh contract — a failed replacement leaves the old token complete — can +/// only be proven by making a replacement fail, and filesystem permissions are not +/// a reliable way to do that: `create_private_dir` repairs the store to 0700 before +/// every write, which undid the read-only directory a previous test relied on, and a +/// privileged runner ignores modes altogether. So the write path asks this hook at +/// the most damaging moment — bytes written, nothing published — and production +/// code compiles it to a no-op. +#[cfg(test)] +pub(crate) mod fault { + use std::cell::Cell; + + thread_local! { + static FAIL_STAGING_WRITE: Cell = const { Cell::new(false) }; + static FAIL_PUBLISH: Cell = const { Cell::new(false) }; + } + + /// Fails every staging write on this thread until dropped. + pub(crate) struct FailStagingWrites; + + impl FailStagingWrites { + pub(crate) fn arm() -> Self { + FAIL_STAGING_WRITE.with(|f| f.set(true)); + Self + } + } + + impl Drop for FailStagingWrites { + fn drop(&mut self) { + FAIL_STAGING_WRITE.with(|f| f.set(false)); + } + } + + pub(crate) fn staging_write_fault() -> std::io::Result<()> { + if FAIL_STAGING_WRITE.with(|f| f.get()) { + Err(std::io::Error::new( + std::io::ErrorKind::StorageFull, + "injected: staging write failed after the bytes were written", + )) + } else { + Ok(()) + } + } + + /// Fails the publish step — the rename over the live path — on this thread + /// until dropped. The staged bytes are complete and durable by then, so this + /// is the moment at which a writer that cleared the live path before renaming + /// would leave no delegation at all. + pub(crate) struct FailPublish; + + impl FailPublish { + pub(crate) fn arm() -> Self { + FAIL_PUBLISH.with(|f| f.set(true)); + Self + } + } + + impl Drop for FailPublish { + fn drop(&mut self) { + FAIL_PUBLISH.with(|f| f.set(false)); + } + } + + pub(crate) fn publish_fault() -> std::io::Result<()> { + if FAIL_PUBLISH.with(|f| f.get()) { + Err(std::io::Error::other( + "injected: publish failed after the staged token was complete", + )) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +use fault::{publish_fault, staging_write_fault}; + +#[cfg(not(test))] +#[inline] +fn staging_write_fault() -> std::io::Result<()> { + Ok(()) +} + +#[cfg(not(test))] +#[inline] +fn publish_fault() -> std::io::Result<()> { + Ok(()) +} + +/// 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) + )) +} + +/// Write `contents`, owner-only from the moment the file exists, and never +/// half-published: staged to a per-call sibling at 0600, synced, then renamed over +/// the live path. See `staging_path` for why the sibling is per-call. +#[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)?; + staging_write_fault()?; + // 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) = publish_fault().and_then(|()| 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)?; + staging_write_fault()?; + file.sync_all() + }; + + if let Err(e) = write() { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + // `rename` replaces an existing destination here too: std maps it to + // `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`. An earlier version removed + // the live file first on the belief that Windows refused the overwrite, which + // opened a window with no delegation at all — the one outcome the staging + // dance exists to prevent. One step, same contract as the Unix path; + // `a_failed_publish_leaves_the_stored_delegation_intact` is what reddens if + // that remove ever comes back. + if let Err(e) = publish_fault().and_then(|()| std::fs::rename(&tmp, path)) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) } async fn cmd_delegate( @@ -85,6 +681,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 gitlawb_core::ucan::push::is_push_wildcard(&cap, &can) { + 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 +734,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,16 +742,19 @@ 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); - println!("Version: {}", ucan.payload.ucan); + // The saved token came from the node at registration: token-derived, so shown + // through the same sanitizer as `verify` and `import`. + println!("Issuer: {}", shown(&ucan.payload.iss.to_string())); + println!("Audience: {}", shown(&ucan.payload.aud.to_string())); + println!("Version: {}", shown(&ucan.payload.ucan)); if ucan.payload.att.is_empty() { println!("Caps: (none)"); } else { for cap in &ucan.payload.att { - println!("Cap: {} → {}", cap.with, cap.can); + println!("Cap: {} → {}", shown(&cap.with), shown(&cap.can)); } } if let Some(exp) = ucan.payload.exp { @@ -166,34 +773,110 @@ async fn cmd_show(dir: Option) -> Result<()> { Ok(()) } -async fn cmd_verify(token: String) -> Result<()> { - // Try as file first, then as raw JSON - let content = if std::path::Path::new(&token).exists() { - std::fs::read_to_string(&token)? - } else { - token - }; +/// What `gl ucan verify` and the MCP `ucan_verify` tool report about a token. +/// +/// One definition for both surfaces, so they cannot disagree about what "valid" +/// means. Both used to answer from the leaf alone — signature and expiry — and +/// called a token valid whose proof chain was broken (a proof signed by the +/// wrong key, an audience that did not match the issuer, a child claiming more +/// than its parent granted). Import and the node then refused it, with nothing +/// on the client side having said why. +pub struct VerifyReport { + /// The leaf's own signature, or why it failed. + pub signature: std::result::Result<(), String>, + pub expired: bool, + /// The root issuer the proof chain walks to, or why the walk failed. + /// [`Ucan::verify_chain`] covers signature and expiry of every link, the + /// leaf included, so `chain` alone decides validity; the other fields say + /// which part of it went wrong. + pub chain: std::result::Result, + pub issuer: String, + pub audience: String, + pub capabilities: Vec<(String, String)>, + pub expires: Option, +} - let ucan = Ucan::decode(&content).context("failed to parse UCAN token")?; +impl VerifyReport { + pub fn of(ucan: &Ucan) -> Self { + Self { + signature: ucan.verify_signature().map_err(|e| e.to_string()), + expired: ucan.is_expired(), + chain: ucan + .verify_chain() + .map(|root| root.to_string()) + .map_err(|e| e.to_string()), + issuer: ucan.payload.iss.to_string(), + audience: ucan.payload.aud.to_string(), + capabilities: ucan + .payload + .att + .iter() + .map(|c| (c.with.clone(), c.can.clone())) + .collect(), + expires: ucan.payload.exp, + } + } - match ucan.verify_signature() { - Ok(()) => println!("Signature: valid"), - Err(e) => println!("Signature: INVALID — {e}"), + /// Signature good, not expired, and the proof chain walks to a root. + pub fn is_valid(&self) -> bool { + self.signature.is_ok() && !self.expired && self.chain.is_ok() } - if ucan.is_expired() { - println!("Expired: yes"); - } else { - println!("Expired: no"); + /// The MCP shape. `valid` is [`Self::is_valid`]; the rest is there so a + /// caller can see which check failed without re-running them. + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "valid": self.is_valid(), + "signature_valid": self.signature.is_ok(), + "expired": self.expired, + "chain_valid": self.chain.is_ok(), + "chain_error": self.chain.as_ref().err(), + "root_issuer": self.chain.as_ref().ok(), + "issuer": self.issuer, + "audience": self.audience, + "capabilities": self.capabilities + .iter() + .map(|(with, can)| serde_json::json!({ "with": with, "can": can })) + .collect::>(), + "expires": self.expires, + }) } - println!("Issuer: {}", ucan.payload.iss); - println!("Audience: {}", ucan.payload.aud); - for cap in &ucan.payload.att { - println!("Cap: {} → {}", cap.with, cap.can); + /// The CLI shape, one line per check. Every value here came out of the token + /// (the error strings too — `verify_chain` quotes `with`, `can`, `iss` and + /// `aud` in them), so each passes through [`shown`] on its way out. + pub fn render(&self) -> String { + let mut out = String::new(); + match &self.signature { + Ok(()) => out.push_str("Signature: valid\n"), + Err(e) => out.push_str(&format!("Signature: INVALID — {}\n", shown(e))), + } + out.push_str(if self.expired { + "Expired: yes\n" + } else { + "Expired: no\n" + }); + match &self.chain { + Ok(root) => out.push_str(&format!("Chain: valid (root {})\n", shown(root))), + Err(e) => out.push_str(&format!("Chain: INVALID — {}\n", shown(e))), + } + out.push_str(&format!("Issuer: {}\n", shown(&self.issuer))); + out.push_str(&format!("Audience: {}\n", shown(&self.audience))); + for (with, can) in &self.capabilities { + out.push_str(&format!("Cap: {} → {}\n", shown(with), shown(can))); + } + out } +} + +async fn cmd_verify(token: String) -> Result<()> { + let content = read_token_argument(&token)?; + let ucan = Ucan::decode(&content).context("failed to parse UCAN token")?; + + let report = VerifyReport::of(&ucan); + print!("{}", report.render()); - if ucan.verify_signature().is_err() || ucan.is_expired() { + if !report.is_valid() { std::process::exit(1); } Ok(()) @@ -348,3 +1031,925 @@ mod tests { .unwrap(); } } + +#[cfg(test)] +mod verify_report_tests { + use super::*; + use gitlawb_core::identity::Keypair; + + fn hour() -> chrono::DateTime { + chrono::Utc::now() + chrono::Duration::hours(1) + } + + /// The round-ten P2: `gl ucan verify` answered from the leaf alone. A leaf with + /// a good signature on a proof that never named its issuer was reported valid, + /// then refused by import and by the node. + #[test] + fn a_broken_proof_chain_is_not_valid() { + let alice = Keypair::generate(); + let bob = Keypair::generate(); + let eve = Keypair::generate(); + let node = Keypair::generate(); + let cap = || vec![Capability::new("gitlawb://repos/alice/r", caps::GIT_PUSH)]; + + let root = Ucan::issue(&alice, bob.did(), cap(), Some(hour())).unwrap(); + let forged = Ucan::delegate(&eve, node.did(), cap(), Some(hour()), &root).unwrap(); + + let report = VerifyReport::of(&forged); + assert!( + report.signature.is_ok(), + "fixture: the leaf signature is valid" + ); + assert!(!report.expired); + let chain_err = report + .chain + .as_ref() + .expect_err("the chain must be reported broken"); + assert!(chain_err.contains("proof chain broken"), "{chain_err}"); + assert!(!report.is_valid()); + + let rendered = report.render(); + assert!(rendered.contains("Signature: valid"), "{rendered}"); + assert!(rendered.contains("Chain: INVALID"), "{rendered}"); + + let json = report.to_json(); + assert_eq!(json["valid"], false); + assert_eq!(json["signature_valid"], true); + assert_eq!(json["chain_valid"], false); + assert!(json["root_issuer"].is_null()); + } + + /// Everything the report prints came out of the token, and `Did` deserializes + /// any string, so a crafted token can put an escape sequence in `iss`, `aud`, + /// `with` or `can` — and, through `verify_chain`'s error text, in the chain + /// line too. None of it may reach the terminal. + #[test] + fn rendered_output_carries_no_terminal_controls_from_the_token() { + let alice = Keypair::generate(); + let node = Keypair::generate(); + let hostile = "gitlawb://repos/x/\u{1b}]0;pwned\u{7}\u{202E}r"; + let mut ucan = Ucan::issue( + &alice, + node.did(), + vec![Capability::new(hostile, "git/push\u{1b}[31m")], + Some(hour()), + ) + .unwrap(); + // A hostile issuer too, by the route a crafted token takes: `Did`'s + // `FromStr` validates, its `Deserialize` does not. The signature no longer + // matches, which is fine — the point is what the lines look like, not + // whether they say "valid". + ucan.payload.iss = serde_json::from_str::("\"did:key:z6Mk\\u001b[2J\"").unwrap(); + + let rendered = VerifyReport::of(&ucan).render(); + assert!( + !rendered.chars().any(|c| c.is_control() && c != '\n'), + "control characters leaked into the report: {rendered:?}" + ); + assert!(!rendered.contains('\u{202E}'), "{rendered:?}"); + assert!( + rendered.contains("pwned"), + "the text itself stays: {rendered}" + ); + } + + #[test] + fn shown_caps_runaway_values_and_marks_the_cut() { + let long = "a".repeat(SHOWN_MAX_CHARS + 50); + let out = shown(&long); + assert_eq!(out.chars().count(), SHOWN_MAX_CHARS + 1); + assert!(out.ends_with('…')); + assert_eq!(shown("plain"), "plain"); + } + + #[test] + fn a_sound_chain_reports_its_root() { + let alice = Keypair::generate(); + let bob = Keypair::generate(); + let node = Keypair::generate(); + let cap = || vec![Capability::new("gitlawb://repos/alice/r", caps::GIT_PUSH)]; + + let root = Ucan::issue(&alice, bob.did(), cap(), Some(hour())).unwrap(); + let leaf = Ucan::delegate(&bob, node.did(), cap(), Some(hour()), &root).unwrap(); + + let report = VerifyReport::of(&leaf); + assert!(report.is_valid()); + assert_eq!( + report.chain.as_deref(), + Ok(alice.did().to_string().as_str()) + ); + assert!( + report + .render() + .contains(&format!("Chain: valid (root {})", alice.did())), + "{}", + report.render() + ); + assert_eq!(report.to_json()["root_issuer"], alice.did().to_string()); + } +} + +#[cfg(test)] +mod token_argument_tests { + use super::*; + + #[test] + fn json_passes_through_without_touching_the_filesystem() { + let raw = r#" {"payload":{}} "#; + assert_eq!(read_token_argument(raw).unwrap(), r#"{"payload":{}}"#); + } + + #[test] + fn a_file_is_read_and_trimmed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.json"); + std::fs::write(&path, " {\"a\":1}\n").unwrap(); + assert_eq!( + read_token_argument(path.to_str().unwrap()).unwrap(), + "{\"a\":1}" + ); + } + + /// A path that exists but cannot be read is an error about the file. It used + /// to fall through to "treat the argument as a token" and fail in `decode` + /// with a message about the path string not being JSON. + #[test] + fn an_unreadable_path_is_reported_as_the_file_problem_it_is() { + let dir = tempfile::tempdir().unwrap(); + let err = read_token_argument(dir.path().to_str().unwrap()) + .expect_err("a directory is not a token file"); + assert!(err.to_string().contains("is not a file"), "{err}"); + } + + #[test] + fn a_file_past_the_cap_is_refused_before_it_is_read() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("huge.json"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(MAX_TOKEN_FILE_BYTES + 1).unwrap(); + let err = read_token_argument(path.to_str().unwrap()).expect_err("must refuse"); + assert!(err.to_string().contains("larger than"), "{err}"); + } + + /// A missing file is indistinguishable from a raw token that is not JSON, and + /// `decode` names both possibilities in its error, so this stays permissive. + #[test] + fn a_missing_path_falls_through_to_decode() { + assert_eq!( + read_token_argument("no-such-token.json").unwrap(), + "no-such-token.json" + ); + } +} + +#[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()); + // Owner-consistent: import binds the resource owner to the chain root, so a + // synthetic `z6MkAbc` owner is refused before the mode assertions are reached. + let (token, owner) = owned_token(&agent, caps::GIT_PUSH, "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(), &owner, "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()); + } +} + +#[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(test)] +mod refresh_atomicity_tests { + use super::delegation_store_tests::{owned_token, seed_identity}; + use super::*; + + /// The writer's own contract: a failed replacement preserves the old token, + /// complete, and leaves nothing half-published behind. + /// + /// Failure is injected, not arranged. The previous version chmod'd the store to + /// 0500 and expected the staging create to fail; `create_private_dir` repairs + /// the store to 0700 before every write, so the second import succeeded and the + /// test failed on `is_err()` — and a privileged CI user would have made the same + /// arrangement pass anyway. The seam fires after the bytes are written and + /// before anything is published, the most damaging point, on every platform. + #[tokio::test] + async fn a_failed_write_leaves_the_stored_delegation_intact() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + 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()); + + let result = { + let _fail = fault::FailStagingWrites::arm(); + cmd_import(good, Some(dir.path().to_path_buf())).await + }; + + assert!(result.is_err(), "the injected staging failure must surface"); + assert_eq!( + std::fs::read(&stored).unwrap(), + before, + "a failed refresh must leave the old token complete, not empty or partial" + ); + let leftovers: Vec<_> = std::fs::read_dir(dir.path().join("delegations")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!( + leftovers.is_empty(), + "a failed staging write must clean up after itself, found {leftovers:?}" + ); + } + + /// The publish step itself failing — staged bytes complete and durable, the + /// rename over the live path refused — must leave the old token in place. + /// This is the moment the non-Unix writer used to have already removed the + /// live file, so a failed rename there left NO delegation; the success-path + /// test below cannot tell that writer from this one, because a rename that + /// succeeds ends in the same state either way. Reinstating the remove reddens + /// this on the platform that had it. + #[tokio::test] + async fn a_failed_publish_leaves_the_stored_delegation_intact() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + 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(); + + let result = { + let _fail = fault::FailPublish::arm(); + cmd_import(good, Some(dir.path().to_path_buf())).await + }; + + assert!(result.is_err(), "the injected publish failure must surface"); + assert!( + stored.exists(), + "a failed publish must not leave the delegation absent" + ); + assert_eq!( + std::fs::read(&stored).unwrap(), + before, + "a failed publish must leave the old token complete" + ); + let leftovers: Vec<_> = std::fs::read_dir(dir.path().join("delegations")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "found {leftovers:?}"); + } + + /// The other half of the contract: a successful refresh replaces the stored + /// token in one step. On Windows this is `rename` over an existing file, which + /// std supports (`MOVEFILE_REPLACE_EXISTING`); the writer used to remove the + /// live file first, on the belief that it did not, and that remove was the + /// only moment the delegation could be absent. + #[tokio::test] + async fn a_successful_refresh_replaces_the_stored_delegation_in_place() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let owner = gitlawb_core::identity::Keypair::generate(); + let bare = owner + .did() + .to_string() + .strip_prefix("did:key:") + .unwrap() + .to_string(); + let token_for = |hours: i64| { + Ucan::issue( + &owner, + me.did(), + vec![Capability::new( + format!("gitlawb://repos/{bare}/myrepo"), + caps::GIT_PUSH, + )], + Some(chrono::Utc::now() + chrono::Duration::hours(hours)), + ) + .unwrap() + .encode() + .unwrap() + }; + let first = token_for(1); + let second = token_for(2); + assert_ne!(first, second); + + cmd_import(first.clone(), Some(dir.path().to_path_buf())) + .await + .expect("first import"); + let stored = delegation_path(dir.path(), &bare, "myrepo"); + assert_eq!(std::fs::read_to_string(&stored).unwrap(), first); + + cmd_import(second.clone(), Some(dir.path().to_path_buf())) + .await + .expect("a refresh over an existing delegation must succeed"); + assert_eq!( + std::fs::read_to_string(&stored).unwrap(), + second, + "the refresh must publish the new token over the old one" + ); + let leftovers: Vec<_> = std::fs::read_dir(dir.path().join("delegations")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "found {leftovers:?}"); + } + + /// 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" + ); + } +} + +#[cfg(test)] +mod chain_scope_tests { + use super::delegation_store_tests::{owned_token, seed_identity}; + use super::*; + + /// A hand-built token naming two repositories is refused before anything is + /// written. `write_private_file` is atomic per file, not across files, so + /// applying such a token could publish one repository's delegation and then + /// fail on the other while reporting failure for both. + #[tokio::test] + async fn import_refuses_a_token_naming_two_repositories() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let owner = gitlawb_core::identity::Keypair::generate(); + let bare = owner + .did() + .to_string() + .strip_prefix("did:key:") + .unwrap() + .to_string(); + let token = Ucan::issue( + &owner, + me.did(), + vec![ + Capability::new(format!("gitlawb://repos/{bare}/first"), caps::GIT_PUSH), + Capability::new(format!("gitlawb://repos/{bare}/second"), caps::GIT_PUSH), + ], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap(); + + let err = cmd_import(token, Some(dir.path().to_path_buf())) + .await + .expect_err("two repositories in one token must be refused"); + assert!( + err.to_string().contains("one repository per token"), + "the refusal must say what the supported shape is: {err}" + ); + for repo in ["first", "second"] { + assert!( + !delegation_path(dir.path(), &bare, repo).exists(), + "nothing may be written for a refused import ({repo})" + ); + } + } + + /// The refusal is about distinct repositories, not distinct capabilities: two + /// push-class actions on one repository are one stored file. + #[tokio::test] + async fn import_accepts_two_capabilities_on_one_repository() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let owner = gitlawb_core::identity::Keypair::generate(); + let bare = owner + .did() + .to_string() + .strip_prefix("did:key:") + .unwrap() + .to_string(); + let resource = format!("gitlawb://repos/{bare}/only"); + let token = Ucan::issue( + &owner, + me.did(), + vec![ + Capability::new(&resource, caps::GIT_PUSH), + Capability::new(&resource, caps::REPO_ADMIN), + ], + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .unwrap() + .encode() + .unwrap(); + + cmd_import(token.clone(), Some(dir.path().to_path_buf())) + .await + .expect("two capabilities on one repository are one delegation"); + assert_eq!( + std::fs::read_to_string(delegation_path(dir.path(), &bare, "only")).unwrap(), + token + ); + } + + /// The round-9 P2: a leaf that names the repository sitting on a proof that is + /// `*`. Attenuation accepts it, the root matches, and the leaf-only check that + /// import used to apply stored it — after which the node's full-chain walk + /// refused every push. Import applies the same walk now and refuses before it + /// touches the store. + #[tokio::test] + async fn import_refuses_a_leaf_whose_proof_is_a_wildcard() { + let dir = tempfile::tempdir().unwrap(); + let me = seed_identity(dir.path()); + let hour = chrono::Utc::now() + chrono::Duration::hours(1); + + // A good delegation already in place, so the test can also prove the + // refused one did not displace it. + let (good, owner_key) = owned_token(&me, caps::GIT_PUSH, "myrepo"); + cmd_import(good, Some(dir.path().to_path_buf())) + .await + .expect("seed import"); + let stored = delegation_path(dir.path(), &owner_key, "myrepo"); + let before = std::fs::read(&stored).unwrap(); + + // owner --*--> intermediary --concrete--> me + let owner = gitlawb_core::identity::Keypair::generate(); + let intermediary = gitlawb_core::identity::Keypair::generate(); + let owner_bare = owner + .did() + .to_string() + .strip_prefix("did:key:") + .unwrap() + .to_string(); + let wildcard_proof = Ucan::issue( + &owner, + intermediary.did(), + vec![Capability::new("*", caps::GIT_PUSH)], + Some(hour), + ) + .unwrap(); + let concrete_leaf = Ucan::delegate( + &intermediary, + me.did(), + vec![Capability::new( + format!("gitlawb://repos/{owner_bare}/other"), + caps::GIT_PUSH, + )], + Some(hour), + &wildcard_proof, + ) + .unwrap(); + assert!( + concrete_leaf.verify_chain().is_ok(), + "the chain is cryptographically valid — that is the point" + ); + + let err = cmd_import( + concrete_leaf.encode().unwrap(), + Some(dir.path().to_path_buf()), + ) + .await + .expect_err("a wildcard proof must be refused at import, not at push"); + assert!( + err.to_string().contains("proof behind it"), + "the error must say the proof, not the leaf, is the problem: {err}" + ); + assert!( + !delegation_path(dir.path(), &owner_bare, "other").exists(), + "nothing may be written for a chain the node would refuse" + ); + assert_eq!( + std::fs::read(&stored).unwrap(), + before, + "the existing delegation must be untouched" + ); + } + + /// Issuance is the first boundary. Minting a push-class wildcard only creates a + /// token that every later stage refuses with less context than this. + #[tokio::test] + async fn delegate_refuses_a_push_class_wildcard() { + let dir = tempfile::tempdir().unwrap(); + let _me = seed_identity(dir.path()); + let audience = gitlawb_core::identity::Keypair::generate(); + + for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] { + let err = cmd_delegate( + audience.did().to_string(), + "*".into(), + can.into(), + Some(24), + None, + Some(dir.path().to_path_buf()), + false, + ) + .await + .expect_err("a wildcard resource with a push-class action must be refused"); + assert!(err.to_string().contains("wildcard"), "{can}: {err}"); + } + + // A non-push wildcard is still fine: nothing downstream refuses it. + cmd_delegate( + audience.did().to_string(), + "*".into(), + caps::GIT_FETCH.into(), + Some(24), + None, + Some(dir.path().to_path_buf()), + false, + ) + .await + .expect("a fetch wildcard is not push-class"); + } +} diff --git a/crates/gl/src/whoami.rs b/crates/gl/src/whoami.rs index 7aa9bca2..bc6aa44b 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 7d2e2c83..cd8c5495 100644 --- a/docs/RUN-A-NODE.md +++ b/docs/RUN-A-NODE.md @@ -143,9 +143,11 @@ During the cooldown your node still earns rewards if it keeps heartbeating. ## Owner-only push -The node requires the authenticated pusher to be the repo owner on **every** -branch. A push whose authenticated DID is not the repo owner is rejected with -HTTP 403 before any ref update is applied. The owner is matched in both the full +On the `git-receive-pack` POST — the push itself — the node requires the +authenticated pusher to be the repo owner, or to present a UCAN whose proof chain +roots at the owner and carries `git/push` for this repository (see *Delegating +push to a CI agent* below). Anyone else is rejected with HTTP 403 before any ref +update is applied, on **every** branch. The owner is matched in both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix. This is on by default, and the default is the point. The node authenticates every @@ -155,6 +157,20 @@ Authentication is not authorization, so without this gate every signed caller ca push to every repository — private ones included — on any branch that is not explicitly protected. +A `git push` crosses two independent gates, and this section is about the second: + +1. **Read visibility**, on the `info/refs` advertisement that precedes every fetch + *and* every push. The node admits the owner, anyone on a public repository, and + a caller that a visibility rule names among its readers; everyone else gets a + 404, since a private repository is not confirmed to exist for a non-reader. A + UCAN plays no part here — a delegation grants no read access. +2. **Push authorization**, on the `git-receive-pack` POST: the owner, or an + owner-rooted, bounded, repository-scoped push delegation. + +Both must pass. On a public repository the first is open to everyone, so a +delegation alone is enough; on a private one the delegate must also be a reader — +see *Private repositories* below. + ### Turning it off ```bash @@ -168,16 +184,68 @@ Do this only for a rolling upgrade whose pushers are not yet the repo owner, and treat it as temporary: while it is off, your node accepts a push to any repository from anyone who can generate a keypair. -### Caution: delegated and CI keys are non-owners +- **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. +- **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 -Push authorization is owner-only today. A UCAN `git/push` capability is verified -but **not yet honored for authorization**, so a delegated key cannot push while -this gate is on, even when it holds a valid capability for the repo. +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. | +| **Every link** names this repository | A delegation's scope is fixed when it is issued. The node walks the whole proof chain, so a `*` resource authorizes nothing — not in the leaf and not in any proof behind it. Refusing only the leaf would not have been enough: attenuation permits narrowing a `*` parent to a concrete child, so one wildcard grant could otherwise reach every repository the owner has or later creates. `gl ucan delegate` therefore refuses to issue a push-class `*` at all. | +| **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. | +| The token is addressed to the pusher | The node requires the proof's audience to equal the invocation's issuer, and `gl ucan import` refuses a token addressed to another identity rather than storing one that can only fail. | + +**Private repositories.** A delegation clears the push gate only. The `info/refs` +advertisement that `git push` fetches first is read-gated, and a UCAN grants no +read access, so on a private repository the delegate must also be a reader: + +```bash +# Owner: let the agent read the whole repository +gl visibility set / --repo --readers did:key:z6MkAgent… +``` -If your automation pushes under its own DID rather than the owner's, it will start -getting 403s. Either have it push as the owner, or set -`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push -rights land — that work is what removes this trade-off. +Without it the agent's push fails at the advertisement with a 404 — the same +answer a stranger gets, because a private repository is not confirmed to exist +for a non-reader — even though the delegation itself is valid and would clear the +push gate. On a public repository this step is unnecessary. The two gates are +independent in the other direction as well: a reader with no delegation is still +refused on the push. + +**One repository per token.** `gl ucan delegate` issues one capability per token, +and `gl ucan import` stores one repository per token. A hand-built token that +names several repositories is refused at import rather than applied to some of +them; ask the owner for one delegation per repository. + +**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. ---