mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 05:20:05 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3acd33f01 | ||
|
|
d58da1e6d2 | ||
|
|
12e68e5463 |
No files matched your search
+4
-8
@@ -3,10 +3,7 @@
|
||||
# All variables are optional unless marked REQUIRED.
|
||||
|
||||
# ── Node identity ─────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# Path to the node's Ed25519 keypair PEM file.
|
||||
# Generate with: gl identity new
|
||||
GITLAWB_KEY=/data/keys/identity.pem
|
||||
|
||||
@@ -101,10 +98,9 @@ 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. 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.
|
||||
# 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.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
|
||||
Generated
-3
@@ -3377,13 +3377,10 @@ name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -66,8 +66,8 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -188,7 +188,12 @@ export GITLAWB_NODE=http://localhost:7545
|
||||
git clone gitlawb://did:key:z6Mk.../my-repo
|
||||
```
|
||||
|
||||
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
|
||||
For public-network use, make sure `GITLAWB_NODE` points to the node you want, over
|
||||
`https://`. The helper defaults to localhost for local development, and plaintext
|
||||
`http://` is allowed only to this machine: requests are signed but not encrypted, so
|
||||
a cleartext hop to a remote node exposes the pack contents and the `Signature`
|
||||
header. A remote `http://` node is refused, and `GITLAWB_ALLOW_INSECURE_HTTP=1`
|
||||
overrides that for a trusted private network.
|
||||
|
||||
### Full lifecycle against an iCaptcha-enforcing node
|
||||
|
||||
@@ -311,7 +316,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 | 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). |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| 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 +398,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 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_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_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. |
|
||||
|
||||
@@ -14,17 +14,8 @@ 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"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -267,6 +267,15 @@ fn write_response(mut stream: TcpStream, status: &str, content_type: &str, body:
|
||||
/// Run `git fetch` in `clone` through the helper, with a hard timeout so a
|
||||
/// regression to the deadlock fails fast instead of hanging the suite.
|
||||
fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Output) {
|
||||
fetch_with_helper_env(clone, node_url, &[])
|
||||
}
|
||||
|
||||
/// [`fetch_with_helper`] with extra environment for the helper process.
|
||||
fn fetch_with_helper_env(
|
||||
clone: &Path,
|
||||
node_url: &str,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> (bool, std::process::Output) {
|
||||
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
|
||||
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
|
||||
let path_env = match std::env::var_os("PATH") {
|
||||
@@ -289,7 +298,14 @@ fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Outpu
|
||||
// machine/CI with git-l10n installed and LANG set to a translated locale.
|
||||
.env("LC_ALL", "C")
|
||||
.env("GITLAWB_NODE", node_url)
|
||||
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-fetch");
|
||||
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-fetch")
|
||||
// The helper inherits this process's environment, so an operator or CI
|
||||
// that exports the insecure-HTTP override would decide the transport
|
||||
// policy for every test below. Clear it and let each test opt in.
|
||||
.env_remove("GITLAWB_ALLOW_INSECURE_HTTP");
|
||||
for (k, v) in extra_env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
run_bounded(cmd, Duration::from_secs(30))
|
||||
}
|
||||
|
||||
@@ -1242,3 +1258,71 @@ fn run_bounded_bounds_join_when_leader_exits_leaving_a_pipe_holder() {
|
||||
held pipes do not stall the joins)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The plaintext-transport gate, proven at its CALL SITE rather than in
|
||||
/// isolation. `main.rs`'s unit tests cover `is_insecure_remote` and
|
||||
/// `check_transport_security` as functions; neither notices if the call in
|
||||
/// `main()` is deleted, which is the regression that would silently restore the
|
||||
/// cleartext hop. Only driving the built binary binds the wiring.
|
||||
///
|
||||
/// `192.0.2.1` is TEST-NET-1 (RFC 5737): non-loopback, reserved for
|
||||
/// documentation, and never routable, so the gate is what stops this and no
|
||||
/// connection is attempted even if it regressed.
|
||||
#[test]
|
||||
fn real_git_fetch_refuses_a_remote_plaintext_node() {
|
||||
// A shallow fixture is enough: the gate fires before any negotiation.
|
||||
let repos = build_divergent_repos(1);
|
||||
let (ok, out) = fetch_with_helper(&repos.clone, "http://192.0.2.1:7545");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
assert!(
|
||||
!ok || !out.status.success(),
|
||||
"the fetch must not succeed: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("plaintext http"),
|
||||
"the helper must refuse the cleartext hop and say so; stderr was: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("GITLAWB_ALLOW_INSECURE_HTTP"),
|
||||
"the refusal must name the escape hatch; stderr was: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A proxy variable must not divert a LOOPBACK node off this machine.
|
||||
///
|
||||
/// reqwest's default client honours `HTTP_PROXY` for a loopback URL, so before
|
||||
/// the fix a proxy pointing off-machine carried the signed plaintext request to
|
||||
/// it, past the transport guard, which only ever inspected the URL. Observed
|
||||
/// directly: `proxy(http://...:9999/) intercepts 'http://127.0.0.1:7545/'`.
|
||||
///
|
||||
/// The proxy here is a black hole on TEST-NET-1, so if the bypass regresses this
|
||||
/// fetch cannot succeed: it either hangs to the harness timeout or fails to
|
||||
/// connect. Success is only possible when the request went straight to the shim.
|
||||
#[test]
|
||||
fn real_git_fetch_ignores_a_proxy_for_a_loopback_node() {
|
||||
let repos = build_divergent_repos(1);
|
||||
let (server, clone) = (repos.server.clone(), repos.clone.clone());
|
||||
let shim = start_shim(server.clone(), ShimMode::Normal);
|
||||
|
||||
let (completed, out) = fetch_with_helper_env(
|
||||
&clone,
|
||||
&shim.base_url,
|
||||
&[
|
||||
("HTTP_PROXY", "http://192.0.2.1:9999"),
|
||||
("http_proxy", "http://192.0.2.1:9999"),
|
||||
("ALL_PROXY", "http://192.0.2.1:9999"),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
completed,
|
||||
"the fetch did not finish; a proxied loopback request would stall here. stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"a loopback fetch must ignore the proxy variables. stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//! 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 `<dir>/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
|
||||
/// `<home>/.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<PathBuf> {
|
||||
// `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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
@@ -30,30 +30,10 @@ 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
|
||||
|
||||
+25
-737
@@ -16,19 +16,6 @@ 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 {
|
||||
@@ -62,32 +49,11 @@ 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;
|
||||
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
|
||||
resource_ok && action_ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,138 +71,6 @@ 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>` → `(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 {
|
||||
@@ -312,37 +146,6 @@ 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 {
|
||||
@@ -445,33 +248,8 @@ impl Ucan {
|
||||
/// 3. Check the proof is not expired
|
||||
/// 4. Recursively verify the proof's own chain
|
||||
///
|
||||
/// 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<Did> {
|
||||
self.verify_chain_at(0)
|
||||
}
|
||||
|
||||
fn verify_chain_at(&self, depth: usize) -> Result<Did> {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted"
|
||||
)));
|
||||
}
|
||||
|
||||
/// A UCAN with no proofs (root capability) passes trivially.
|
||||
pub fn verify_chain(&self) -> Result<()> {
|
||||
// First verify our own signature
|
||||
self.verify_signature()?;
|
||||
|
||||
@@ -483,44 +261,34 @@ impl Ucan {
|
||||
return Err(Error::Ucan("token is not yet valid".to_string()));
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
for proof_token in &self.payload.prf {
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
"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!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the proof's signature and chain recursively
|
||||
proof.verify_chain()?;
|
||||
}
|
||||
|
||||
// Recurse; the root of the proof's chain is the root of ours.
|
||||
proof.verify_chain_at(depth + 1)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,484 +664,4 @@ 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> {
|
||||
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> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,16 @@ 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 {
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
|
||||
@@ -1861,21 +1861,14 @@ fn owner_push_rejection(
|
||||
enforce: bool,
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: Option<&str>,
|
||||
verified: Option<&crate::auth::VerifiedUcan>,
|
||||
) -> Option<AppError> {
|
||||
if !enforce {
|
||||
return None;
|
||||
}
|
||||
match caller {
|
||||
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(did) if caller_authorized_to_push(record, did) => None,
|
||||
_ => Some(AppError::Forbidden(
|
||||
"push rejected — you must be the repo owner, or hold a valid \
|
||||
owner-issued git/push delegation for this repository \
|
||||
"push rejected — only the repo owner may push to this repository \
|
||||
(GITLAWB_ENFORCE_OWNER_PUSH is enabled)"
|
||||
.into(),
|
||||
)),
|
||||
@@ -2015,10 +2008,6 @@ pub async fn git_receive_pack(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
// `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<Extension<crate::auth::VerifiedUcan>>,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: Bytes,
|
||||
@@ -2066,7 +2055,6 @@ 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,
|
||||
@@ -2078,17 +2066,9 @@ pub async fn git_receive_pack(
|
||||
}
|
||||
|
||||
// ── Branch protection check ──────────────────────────────────────────
|
||||
// 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.
|
||||
// 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.)
|
||||
for update in &ref_updates {
|
||||
// Strip refs/heads/ prefix to get plain branch name
|
||||
let branch = update
|
||||
@@ -3643,7 +3623,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), None).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3651,92 +3631,36 @@ 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), None).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).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), None));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID)));
|
||||
}
|
||||
|
||||
#[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, None));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, 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), 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));
|
||||
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, None).is_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, None));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_DID));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID));
|
||||
}
|
||||
|
||||
// ── fork_withheld_blocks (#98 path-scoped fork gate) ──
|
||||
@@ -6016,7 +5940,6 @@ 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"),
|
||||
@@ -6035,7 +5958,6 @@ 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"),
|
||||
@@ -6142,7 +6064,6 @@ 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"),
|
||||
@@ -6230,7 +6151,6 @@ 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"),
|
||||
@@ -6388,7 +6308,6 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -6543,7 +6462,6 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -6634,7 +6552,6 @@ 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"),
|
||||
@@ -6653,7 +6570,6 @@ 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"),
|
||||
@@ -7302,7 +7218,6 @@ 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::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body(new_sha),
|
||||
@@ -7397,7 +7312,6 @@ 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"),
|
||||
@@ -7458,7 +7372,6 @@ 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"),
|
||||
@@ -8684,7 +8597,6 @@ 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::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8713,7 +8625,6 @@ 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::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -8810,7 +8721,6 @@ 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::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8904,7 +8814,6 @@ 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::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -8932,7 +8841,6 @@ 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::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -9117,7 +9025,6 @@ 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"),
|
||||
@@ -9191,7 +9098,6 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6ovflow".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("1111111111111111111111111111111111111111"),
|
||||
@@ -9246,7 +9152,6 @@ 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"),
|
||||
@@ -9352,7 +9257,6 @@ 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"),
|
||||
@@ -9443,7 +9347,6 @@ 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"),
|
||||
@@ -9537,7 +9440,6 @@ 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"),
|
||||
@@ -9598,7 +9500,6 @@ 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"),
|
||||
@@ -9643,7 +9544,6 @@ 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"),
|
||||
@@ -10154,7 +10054,6 @@ 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::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
|
||||
@@ -17,74 +17,16 @@ 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`.
|
||||
///
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
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::{
|
||||
@@ -328,7 +270,7 @@ fn validate_ucan_chain(
|
||||
token: &str,
|
||||
expected_aud: &Did,
|
||||
signer_did: &Did,
|
||||
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
let ucan = Ucan::decode(token).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -356,14 +298,14 @@ fn validate_ucan_chain(
|
||||
)
|
||||
})?;
|
||||
|
||||
let root = ucan.verify_chain().map_err(|e| {
|
||||
ucan.verify_chain().map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(VerifiedUcan { ucan, root })
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
|
||||
@@ -416,18 +358,11 @@ pub async fn require_ucan_chain(
|
||||
}
|
||||
};
|
||||
|
||||
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
Ok(v) => v,
|
||||
Err((status, body)) => return (status, body).into_response(),
|
||||
};
|
||||
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
return (status, body).into_response();
|
||||
}
|
||||
|
||||
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
|
||||
|
||||
// Park the verified token where a handler can reach it. Validation alone
|
||||
// grants nothing; the authorization decision is made downstream, by a caller
|
||||
// that knows which identity it trusts for the resource being touched.
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(verified);
|
||||
tracing::debug!(did = %signer_did, "UCAN chain validated");
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -463,37 +398,6 @@ 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,
|
||||
@@ -733,407 +637,3 @@ 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<Capability>) -> VerifiedUcan {
|
||||
verified_with_exp(
|
||||
root,
|
||||
caps_vec,
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
}
|
||||
|
||||
fn verified_with_exp(
|
||||
root: &str,
|
||||
caps_vec: Vec<Capability>,
|
||||
exp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2390,456 +2390,6 @@ 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<chrono::DateTime<chrono::Utc>>| {
|
||||
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<String>| {
|
||||
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 <repo> --readers <agent>` 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
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -73,18 +73,17 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
println!("gl doctor — checking your gitlawb setup");
|
||||
println!();
|
||||
|
||||
// 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 dir = args.dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
|
||||
let mut checks = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = crate::identity::key_path_for(args_dir.as_deref())?;
|
||||
let pem_path = dir.join("identity.pem");
|
||||
if pem_path.exists() {
|
||||
match std::fs::read_to_string(&pem_path)
|
||||
.ok()
|
||||
|
||||
@@ -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 {
|
||||
gitlawb_core::sanitize::strip_terminal_controls(s)
|
||||
.chars()
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
+20
-239
@@ -9,8 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
pub enum IdentityCmd {
|
||||
/// Generate a new Ed25519 keypair and DID
|
||||
New {
|
||||
/// Output directory for key files
|
||||
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Output directory for key files (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Overwrite existing keys if present
|
||||
@@ -64,51 +63,16 @@ pub async fn run(cmd: IdentityCmd) -> 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<PathBuf>) -> Result<PathBuf> {
|
||||
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(d) = override_dir {
|
||||
return Ok(d);
|
||||
}
|
||||
// `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}"))
|
||||
let home = dirs::home_dir().context("could not determine home directory")?;
|
||||
Ok(home.join(".gitlawb"))
|
||||
}
|
||||
|
||||
/// The identity PEM to read or write.
|
||||
///
|
||||
/// With an explicit `--dir` this is `<dir>/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<PathBuf> {
|
||||
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 key_path(dir: &Path) -> PathBuf {
|
||||
dir.join("identity.pem")
|
||||
}
|
||||
|
||||
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
@@ -117,14 +81,15 @@ fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
|
||||
/// 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<Keypair> {
|
||||
let path = key_path_for(dir)?;
|
||||
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 pem = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no identity found at {}\nRun `gl identity new` to create one",
|
||||
@@ -143,11 +108,8 @@ async fn cmd_new_with_reader(
|
||||
force: bool,
|
||||
reader: &mut impl std::io::BufRead,
|
||||
) -> Result<()> {
|
||||
let path = key_path_for(dir.as_deref())?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let dir = gitlawb_dir(dir)?;
|
||||
let path = key_path(&dir);
|
||||
|
||||
if path.exists() {
|
||||
if force {
|
||||
@@ -221,7 +183,8 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
let src = key_path_for(dir.as_deref())?;
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let src = key_path(&base);
|
||||
|
||||
let pem = fs::read_to_string(&src).with_context(|| {
|
||||
format!(
|
||||
@@ -276,11 +239,8 @@ 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 dest = key_path_for(dir.as_deref())?;
|
||||
let base = dest
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let dest = key_path(&base);
|
||||
|
||||
if dest.exists() {
|
||||
if force {
|
||||
@@ -549,182 +509,3 @@ 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<OsString>,
|
||||
}
|
||||
|
||||
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<T, V: AsRef<OsStr>>(value: Option<V>, 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<V: AsRef<OsStr>>(value: Option<V>) -> 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<T>(value: Option<OsString>, 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -22,7 +22,7 @@ pub struct InitArgs {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -101,7 +101,10 @@ 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 = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
let ucan_dir = args
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
std::fs::create_dir_all(&ucan_dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -218,15 +221,18 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
|
||||
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("."));
|
||||
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 keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = base.join("identity.pem");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ pub enum IpfsCmd {
|
||||
List {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
+33
-415
@@ -283,7 +283,7 @@ fn tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "ucan_show",
|
||||
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
|
||||
"description": "Show the saved bootstrap UCAN token for this agent.",
|
||||
"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. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
|
||||
"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", "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." }
|
||||
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_verify",
|
||||
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
|
||||
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
@@ -583,17 +583,6 @@ 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 ─────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -770,45 +759,15 @@ async fn call_tool(
|
||||
]))?),
|
||||
|
||||
"ucan_show" => {
|
||||
// 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 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())
|
||||
}
|
||||
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::<Vec<_>>(),
|
||||
"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" => {
|
||||
@@ -1166,35 +1125,14 @@ 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/<owner>/<repo>."
|
||||
);
|
||||
}
|
||||
|
||||
let audience: gitlawb_core::did::Did = to_str
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// 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 exp = args
|
||||
.get("expiry_hours")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
|
||||
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&kp,
|
||||
@@ -1217,26 +1155,23 @@ 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")?;
|
||||
// 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())?)
|
||||
}
|
||||
let sig_valid = ucan.verify_signature().is_ok();
|
||||
let expired = ucan.is_expired();
|
||||
let caps: Vec<Value> = ucan
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect();
|
||||
|
||||
"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!({
|
||||
"owner": imported.owner,
|
||||
"repo": imported.repo,
|
||||
"root_issuer": imported.root,
|
||||
"expires": imported.expires,
|
||||
"path": imported.path.display().to_string(),
|
||||
"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,
|
||||
}))?)
|
||||
}
|
||||
|
||||
@@ -1629,7 +1564,6 @@ mod tests {
|
||||
.collect();
|
||||
assert!(names.contains(&"ucan_delegate"));
|
||||
assert!(names.contains(&"ucan_verify"));
|
||||
assert!(names.contains(&"ucan_import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1796,38 +1730,6 @@ 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();
|
||||
@@ -1856,204 +1758,6 @@ 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]
|
||||
@@ -2323,95 +2027,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_matches_the_shipped_list() {
|
||||
fn test_tool_count_is_42() {
|
||||
let tools = tool_definitions();
|
||||
let count = tools.as_array().unwrap().len();
|
||||
// 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::<serde_json::Value>(&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::<serde_json::Value>(&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}");
|
||||
assert_eq!(count, 40, "expected 40 tools, got {count}");
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -168,8 +168,16 @@ pub async fn run(args: NameArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
|
||||
dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
})
|
||||
}
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
@@ -182,7 +190,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
}
|
||||
|
||||
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
@@ -22,7 +22,7 @@ pub enum NodeCmd {
|
||||
Status {
|
||||
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
|
||||
node: String,
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
@@ -328,10 +328,12 @@ pub async fn cmd_unstake(
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
// 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 base = dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
let path = base.join("identity.pem");
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
+20
-46
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -39,26 +39,19 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!("and create your first repository.");
|
||||
println!();
|
||||
|
||||
// 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())?;
|
||||
let dir = args.dir.clone().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
|
||||
// ── Step 1: Identity ──────────────────────────────────────────────────
|
||||
println!("── Step 1: Identity ─────────────────────────────────────────────────");
|
||||
println!();
|
||||
|
||||
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
|
||||
let pem_path = dir.join("identity.pem");
|
||||
let keypair = if pem_path.exists() {
|
||||
// `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()) {
|
||||
match load_keypair_from_dir(Some(&dir)) {
|
||||
Ok(kp) => {
|
||||
let did = kp.did();
|
||||
println!(" ✓ Identity already exists");
|
||||
@@ -66,15 +59,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!();
|
||||
kp
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Identity at {} is unreadable: {e}", pem_path.display());
|
||||
println!(" Regenerating — the previous key cannot be recovered.");
|
||||
generate_identity(&dir, &pem_path)?
|
||||
Err(_) => {
|
||||
println!(" Identity file exists but is unreadable. Regenerating...");
|
||||
generate_identity(&dir)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" No identity found. Generating a new Ed25519 keypair...");
|
||||
generate_identity(&dir, &pem_path)?
|
||||
generate_identity(&dir)?
|
||||
};
|
||||
|
||||
let did = keypair.did().to_string();
|
||||
@@ -107,14 +99,7 @@ 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("");
|
||||
// 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 {
|
||||
if !ucan.is_empty() {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -123,20 +108,11 @@ 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}");
|
||||
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!(" UCAN saved to {}", ucan_path.display());
|
||||
println!();
|
||||
}
|
||||
Ok(resp) => {
|
||||
@@ -250,24 +226,22 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(
|
||||
dir: &PathBuf,
|
||||
path: &std::path::Path,
|
||||
) -> Result<gitlawb_core::identity::Keypair> {
|
||||
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
|
||||
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();
|
||||
|
||||
+17
-77
@@ -1,8 +1,7 @@
|
||||
//! `gl register` — register this agent identity with a gitlawb node.
|
||||
//!
|
||||
//! Sends a signed POST /api/register request and saves the returned bootstrap
|
||||
//! UCAN token as `ucan.json` beside the identity key, where the other commands
|
||||
//! look for it.
|
||||
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
@@ -30,7 +29,7 @@ pub struct RegisterArgs {
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -70,20 +69,17 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
// Save bootstrap UCAN
|
||||
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let saved_to = if ucan.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let path = ucan_path(args.dir.as_deref())?;
|
||||
if !ucan.is_empty() {
|
||||
let ucan_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(&path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", path.display());
|
||||
Some(path)
|
||||
};
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
|
||||
let trust = payload
|
||||
.get("trust_score")
|
||||
@@ -103,35 +99,21 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN expires: {expires}");
|
||||
println!();
|
||||
// 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.");
|
||||
}
|
||||
}
|
||||
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
|
||||
println!(" You are now a verified agent on the gitlawb network.");
|
||||
|
||||
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<PathBuf> {
|
||||
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()))?;
|
||||
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)?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
@@ -178,48 +160,6 @@ 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();
|
||||
|
||||
+39
-1644
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WhoamiArgs {
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Node URL to query for registration info
|
||||
|
||||
+11
-79
@@ -143,11 +143,9 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
## Owner-only push
|
||||
|
||||
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
|
||||
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
|
||||
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
|
||||
This is on by default, and the default is the point. The node authenticates every
|
||||
@@ -157,20 +155,6 @@ 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
|
||||
@@ -184,68 +168,16 @@ 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.
|
||||
|
||||
- **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.
|
||||
### Caution: delegated and CI keys are non-owners
|
||||
|
||||
### 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/<owner-did>/<repo> --can git/push --expiry 168
|
||||
|
||||
# Agent:
|
||||
gl ucan import <token-or-path>
|
||||
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 <repo> --readers did:key:z6MkAgent…
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user