Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 1b5cb7d0be test(gl): count the MCP tool list at 41 with ucan_import
`e3314c6` added the tool and left the count test at 40, so `test
(windows)` went red first. The test also carried "42" in its name while
asserting 40; it is now named for what it checks, and the names test
covers `ucan_import` alongside the other two UCAN tools.
2026-09-08 20:39:43 +05:30
Vasanthdev2004 e3314c611c fix(gl,core): sanitize token-derived output, prove the publish step, add MCP import
Round eleven of #331, second half.

Every string a token carries is chosen by whoever built it: `Did`
deserializes any string (only `FromStr` validates), and a capability
field is free text. `gl ucan verify`, `gl ucan show`, and the import
refusal messages printed them raw, so an ANSI or OSC sequence in `with`,
`can`, `iss` or `aud` — or in the error text `verify_chain` quotes them
into — reached the operator's terminal. The strip is now one definition
in core, `sanitize::strip_terminal_controls` (both halves of INV-6), and
`gl`'s existing `sanitize_node_msg` is that plus its length cap. In
`ucan_cmd`, `shown()` wraps every token-derived value on its way out.

The refresh-replacement test could not tell the current non-Unix writer
from the one that removed the live file before renaming, because a
rename that succeeds ends in the same state either way. A second seam,
`fault::FailPublish`, fails the rename after the staged bytes are
complete; the new test requires the old token to survive that, and
reinstating the remove reddens it on the platform that had it.

The MCP server could issue and verify a delegation but not store it
where `git-remote-gitlawb` looks. `cmd_import` is now a printer over
`import_delegation`, and the `ucan_import` tool is the same function
with a JSON answer: one set of checks, one store.
2026-09-08 20:34:14 +05:30
Vasanthdev2004 8b91f2841a fix(gl): store one repository per imported delegation; pin the two-gate composition
Round eleven of #331, code half.

`gl ucan import` validated every capability up front and then wrote one
file per repository, and `write_private_file` is atomic per file, not
across files: a token naming two repositories could publish the first
delegation, fail on the second, and report failure for both. `gl ucan
delegate` and the MCP tool issue one capability per token, so such a
token is hand-built; import now refuses it with the supported shape in
the message. Several capabilities on one repository are still one file.

`delegated_push_on_a_private_repo_needs_read_visibility_too` pins the
composition the docs now describe, through the middleware production
gives each route: on a private repository a valid delegation is refused
at the `info/refs` advertisement (404) until the owner names the agent
as a reader, clears the receive-pack POST on its own, and a reader with
no delegation is still refused on the push. Making the visibility check
admit everyone reddens the advertisement assertion.
2026-09-08 20:26:19 +05:30
Vasanthdev2004 fb831b1037 docs: state the two gates a delegated push crosses, and finish the sweep
Round eleven of #331, documentation half.

A `git push` crosses two independent gates: read visibility on the
`info/refs` advertisement, and push authorization on the receive-pack
POST. This PR implements the second and left the first as it was, and a
UCAN grants no read access — so on a private repository a delegate must
also be a reader. Nothing said so. RUN-A-NODE now states the contract
once, in the owner-push section, and the delegation section carries a
"Private repositories" note with the `gl visibility set` invocation and
a "One repository per token" note matching what import enforces.

The rest is the drift the earlier doc edits left behind: the README
env-var table still said a `git/push` capability was "verified but not
yet honored", the known-limitations bullet still said enforcement
defaults to `false`, and the RUN-A-NODE section opened with a blanket
403 for every non-owner two paragraphs above the text that says a
delegate can push. All three now describe the same node.
2026-09-08 20:24:45 +05:30
Vasanthdev2004 1da425158a fix(core,gl): bound every proof-chain walk and make verify report the chain
Round ten of #331.

`verify_chain` and `chain_lifetime_is_bounded` recursed with no depth
guard while `chain_grants_push_to` carried one. The node verifies the
chain on the `X-Ucan` header of any signed request before the scope walk
runs, so the bound protected nothing: a deep chain met the unbounded
walk first. Both now stop at `MAX_CHAIN_DEPTH`, which moves to the
module root since all three walks share it, and a chain at the bound
still validates end to end.

The MCP `ucan_delegate` tool issued an unbounded token when
`expiry_hours` was omitted, which import and the node both refuse for
push. It now defaults to the CLI's 720 hours and refuses a non-positive
value.

`gl ucan verify` and the MCP `ucan_verify` tool answered from the leaf
alone, so a good signature on a broken proof chain reported valid and
then failed at import and at the node. Both report through one
`VerifyReport`, which walks the chain; `valid` requires it.

The non-Unix `write_private_file` removed the live delegation before
`rename`, on the belief that Windows refuses to replace an existing
destination. std maps `rename` to `MoveFileExW` with
`MOVEFILE_REPLACE_EXISTING`, so the remove only opened a window with no
delegation at all. Dropped; a refresh over an existing token is now
tested on every platform.

Also: `cmd_import` and `cmd_verify` share one token-argument reader that
reports an unreadable path as a file problem and caps the file size,
instead of falling through to a decode error on the path string; and
the MCP `ucan_delegate` description says import is CLI-only.
2026-09-08 18:02:22 +05:30
Vasanthdev2004 4dc62b21ca fix(node): drop repo_capability_matches, orphaned by the shared push rule
Round nine routed the node's authorization through
Ucan::chain_grants_push_to, which parses the resource and compares
owners with did_key_eq itself. The thin wrapper left behind had no
callers, and CI's -D warnings build rejects dead code. Nothing else in
the crate referenced it.
2026-09-08 09:25:31 +05:30
Vasanthdev2004 f79309b63c fix(core,node,gl,git-remote): one definition of a usable push capability
Round nine. Both reviewers named the same pattern independently: the rule for
"can this token push to this repository?" was reconstructed at every boundary —
issuance, import, invocation construction, node authorization — and each round
found a token one boundary accepted and the next refused. This round's own
instance: the node walked the whole proof chain, import checked only the leaf, so
a concrete leaf on a wildcard proof imported cleanly and 403'd on every push.

The rule now lives once, in `gitlawb_core::ucan::push`, the crate all four
already depend on:

  did_key_eq            the node's exact did:key collapse rule — bare and full
                        forms are one identity; a bare id never matches across
                        methods (`did:gitlawb:X` is not `X`)
  is_push_action        git/push, "*", repo/admin
  parse_repo_resource   gitlawb://repos/<owner>/<repo>, exactly two segments
  is_push_wildcard      a push-class action on a "*" resource
  Capability::grants_push_to(owner, repo)
                        push-class, unconstrained, names exactly this repo
  Ucan::chain_grants_push_to(owner, repo)
                        the leaf AND every proof behind it, depth-bounded,
                        failing closed on undecodable or multi proofs

and every boundary calls it:

  node       ucan_grants_push = root anchored && bounded && chain_grants_push_to.
             `repo_capability_matches` is a thin wrapper; `proofs_name_repo` and
             `push_class_names_repo` are gone. `did_matches` delegates to
             did_key_eq so there is one collapse rule, not two.
  import     the same walk, before any write. A leaf naming the repo on a "*"
             proof is refused with a message that names the proof. Everything is
             validated before anything is written.
  helper     `build_invocation` refuses a stored token whose chain does not grant
             push to this repo, with a message that says why, before selecting a
             leaf — instead of minting an invocation the node is certain to refuse.
  issuance   `gl ucan delegate` and the MCP `ucan_delegate` tool share
             is_push_wildcard. The MCP arm had no guard at all.

Two tests that failed CI, both invisible on this Windows host because they are
cfg(unix):

- `import_creates_the_store_and_token_owner_only` still issued against a
  synthetic `z6MkAbc` owner, so import's owner-root binding refused it before the
  mode assertions. Now built from `owned_token`, like its siblings.

- `a_failed_write_leaves_the_stored_delegation_intact` chmod'd the store to 0500
  and expected the staging create to fail. It did not: `create_private_dir`
  repairs the store to 0700 before every write, so the second import succeeded
  and the test failed on `is_err()` — and a privileged runner would have made the
  arrangement pass anyway. Failure is now injected through a test-only seam
  (`fault::FailStagingWrites`) that fires after the bytes are written and before
  anything is published, on every platform, and the test also asserts no staging
  file is left behind. The module is no longer unix-only.

Also: the node's rustdocs for `repo_capability_matches` and `ucan_grants_push`
described the pre-round-8 behaviour (helper narrowing; leaf-only coverage) and now
match the code. An orphaned docblock above `staging_path` — the old
`write_private_file` doc, left behind in round 8 and claiming "not create_new"
when the writer uses exactly that — is removed and the writer has a correct one.

Verified by mutation, each restored byte-for-byte afterwards:

  core walks no proofs        → core 3-link, node 2-link, node 3-link, import,
                                and helper wildcard tests all red
  core walks ONE proof only   → ONLY the two 3-link tests red; every 2-link test
                                stays green — the recursion is load-bearing
  MCP guard removed           → MCP deny test red, MCP named-resource test green
  staging seam disconnected   → atomicity test red
  import chain check removed  → import deny test red, delegate deny test green

fmt, clippy -D warnings, check --locked --workspace --all-targets clean.
gitlawb-core 122, gl 389, git-remote-gitlawb 65, node auth:: 23, and the DB-backed
delegated-push tests pass alongside main's owner-push test.
2026-09-08 09:02:45 +05:30
Vasanthdev2004 04e794129f fix(node): pass the UCAN extension at the last two receive-pack call sites
Two `git_receive_pack` calls in `#[cfg(unix)]` test blocks still supplied six
arguments. Like the rest of that class they do not compile on this Windows host,
so neither the local build nor clippy could see them; CI found them one at a time.

Rather than wait for a third round, every call site was audited by grep instead of
by compiler: 25 calls, 2 missing, both fixed, none others. The other signatures
this branch changed — `caller_authorized_to_push`, `identity_key_path`,
`identity_dir`, `key_path_for` — were audited the same way and are consistent.

Both sites push as a synthetic non-owner DID under an owner-push-disabled config,
so `None` is correct: no delegation is involved in what they test (write-lock
release on disconnect, and on push success).
2026-08-28 01:32:12 +05:30
Vasanthdev2004 9cf48f52ba fix(gl): drop an unused import in the unix-only refresh tests
`refresh_atomicity_tests` moved from `token_for_agent` to `owned_token` when
import began requiring the resource owner to equal the verified root, and the
import was left behind. The module is `#[cfg(all(test, unix))]`, so it does not
compile on this Windows host and no local clippy run could see it.

Cross-linting is not available either: `cargo clippy --target
x86_64-unknown-linux-gnu` fails in ring's build script for want of a C
cross-compiler. CI is the only linter for cfg(unix) code from here, which is worth
recording rather than rediscovering.

The sibling `import_binding_tests` still uses both helpers; only the unix module's
import changed.
2026-08-28 01:19:26 +05:30
Vasanthdev2004 89fa647027 Merge origin/main into feat/ucan-push-authorization
#330 landed on main, which puts GITLAWB_ENFORCE_OWNER_PUSH=true in the tree this
branch builds on — so the delegated-push path here is now the thing that keeps CI
keys and agents working, rather than a future improvement.

Merged rather than rebased on purpose. The branch was 116 commits behind with 22
of its own, and several of those touch the same three files; a rebase asked me to
resolve the same conflicts repeatedly, which is where a wrong resolution gets made
quietly. One resolution pass, and the commit history the reviews refer to stays
addressable.

Five conflicts:

- `crates/gitlawb-core/src/lib.rs` — both module declarations kept.

- `crates/gitlawb-node/src/api/repos.rs` — main changed the DIDs these handler
  tests push as; this branch added the `Option<Extension<VerifiedUcan>>` argument.
  Both. Two further call sites in tests main added after the merge base needed the
  same argument, which no merge could have supplied: the parameter exists only on
  this side. Both push as the owner, so `None` is correct.

- `crates/gitlawb-node/src/test_support.rs` — git had interleaved two different
  tests by aligning their shared tail. Rebuilt from main's file plus this branch's
  260-line addition (exactly two tests) rather than untangling that by hand; main's
  tests are byte-identical, verified by hashing the bodies.

- `.env.example` and `docs/RUN-A-NODE.md` — main's text says a UCAN `git/push`
  capability is "verified but not yet honored for authorization", and points at
  "scoped collaborator / UCAN-delegated push rights" as the work that removes the
  trade-off. That work is this branch, so keeping main's wording would have shipped
  a claim this branch falsifies. Main's `true` default is kept; the prose around it
  now describes delegated push, including the round-eight rule that every link in
  the chain must name the repository.

Verified: fmt, clippy -D warnings, and check --locked --workspace --all-targets are
clean. gitlawb-core 116, gl 384, git-remote-gitlawb 64, node auth:: 21 — all green.
Both this branch's delegated-push tests and main's
`enforced_owner_push_refuses_a_signed_non_owner` pass together, which is the
specific thing this merge could have broken.

The node suite also reports 118 failures on this Windows host, all in the
`ipfs_cid_*` and `sync::*promisor*` classes. They fail in fixture setup with
`Os { code: 267, kind: NotADirectory }` before reaching any code under test, and
their bodies are byte-identical to main's. Environmental, not from this merge;
Linux CI covers them.
2026-08-28 01:06:27 +05:30
Vasanthdev2004 1ab55e5f3b fix(node,gl,git-remote): fix a delegation's scope at issuance
Round eight. The round-seven wildcard fix closed the wrong half, and I confirmed
that by execution before changing anything:

    PROBE ucan_grants_push = true

An owner issues `with: "*"`, the delegate narrows it to a concrete leaf for a
repository created afterwards, `verify_chain` passes, and the node authorised the
push. Refusing a wildcard LEAF did nothing, because `is_attenuated_by` accepts a
concrete child under a `*` parent — and that narrowing is exactly what
`build_invocation` performed, so the first-party helper was the working mint path.
The round-seven tests never saw it: they injected a self-issued `*` leaf with no
`prf`, so they never touched a proof chain at all.

The contract is now that a delegation's scope is fixed when it is issued, which is
what both operator documents already promised:

- The node requires EVERY link to name the repository, not just the leaf
  (`proofs_name_repo` walks `prf` after `verify_chain` has established the chain).
- `git-remote-gitlawb` no longer selects or narrows a `*` capability, so a stored
  wildcard fails closed locally with a clear message instead of minting an
  invocation the node would refuse.
- `gl ucan delegate` refuses to issue a push-class wildcard at all.

Also, all from round eight:

- `gl quickstart` loads the exact key it selected. It resolved `pem_path` through
  `key_path_for` but then loaded through `Some(&dir)`, which re-derived the
  basename as `identity.pem` — so with `GITLAWB_KEY=/data/keys/ci-agent.pem` the
  existence check and the load disagreed, and the Err arm regenerated ONTO
  `ci-agent.pem`, destroying a working key and changing the DID that repository
  ownership, registrations, and delegations all key off.

- `gl ucan import` binds every capability's owner segment to the VERIFIED ROOT.
  `verify_chain` proves a chain is internally valid; it says nothing about which
  repository it applies to. Without this, any key holder could issue a valid
  bounded token naming `gitlawb://repos/<victim>/repo` and displace the working
  delegation for a repository they have no authority over. The whole set is
  validated before anything is written, so a later bad capability cannot leave a
  multi-repository import half applied.

- Import applies the same push-class rule the helper and node use, including
  `constraints.is_none()`. A constrained-only grant used to import cleanly and
  then authorise nothing.

- Delegation replacement stages to a per-call path (`create_new`, pid + counter)
  rather than a shared `.<name>.tmp`, so two concurrent refreshes cannot write the
  same inode and publish each other's bytes. The non-Unix path stages and renames
  too; it previously truncated the live file, so a failed refresh destroyed a
  working token on that platform.

Three tests that were weaker than they read, all mine:

- `a_refused_reimport_leaves_the_stored_delegation_intact` never reached
  `write_private_file` — it stopped at the audience check. Renamed to say what it
  covers, and `a_failed_write_leaves_the_stored_delegation_intact` now forces a
  real write failure against a read-only store.
- The MCP token-withholding assertion used `!out.contains(&token)`, which passes
  whether or not the token leaked: the token is itself JSON, so embedding it
  escapes every quote. It now asserts on the signature, a bare base64 string that
  survives escaping.
- `build_invocation_narrows_a_wildcard_to_the_pushed_repo` asserted the behaviour
  this commit removes; it is now `build_invocation_refuses_a_wildcard_delegation`.
2026-08-23 00:34:39 +05:30
Vasanthdev2004 ec4399cee8 fix(gl): drop a needless borrow in quickstart's unix key write
`generate_identity` now takes `path: &Path` rather than deriving it from an owned
directory, so `set_permissions(&path, ..)` became a double borrow. Clippy refuses
it under `-D warnings`.

It sits inside `#[cfg(unix)]`, which is why the local gate passed: this machine is
Windows, so that block is never compiled or linted here. Only CI sees it. The
sibling `fs::write` on the line above was already corrected; this one was missed
because the same pass could not fail locally.

The other `&path` / `&dest` / `&tmp` borrows in the unix blocks this PR touches
are on owned `PathBuf`s, where the borrow is required — `quickstart` is the sole
place a parameter changed from owned to reference, and so the sole place the lint
applies.
2026-08-23 00:13:49 +05:30
Vasanthdev2004 a47ea93152 fix(gl): align the MCP ucan_show tool with gl ucan show
The two surfaces answer the same question and answered it in different shapes:
`gl ucan show` decodes the envelope and prints fields, while the MCP tool returned
the file verbatim. That was the round-seven non-blocking note.

Aligning them removes a second problem the note did not name. The file contains
the bootstrap token, so the MCP tool handed the caller the credential itself —
and an MCP response travels further than a terminal does, into a model's context,
transcripts, and logs. Nothing needs the token to answer "am I registered, with
what, until when", so the tool now returns issuer, audience, version,
capabilities, expiry, expired, signature validity, and the path it read.

The tool description said "Show the saved bootstrap UCAN token"; it now says what
is returned and that the token is not.

Verified by mutation: restoring the raw-file return turns
`ucan_show_returns_decoded_fields_and_withholds_the_token` red on the assertion
that the token is absent.
2026-08-22 14:28:16 +05:30
Vasanthdev2004 82f2484c39 fix(node,gl,git-remote): enforce repo narrowing at the node, honour the key basename
Round seven. Two P1s, and the first changes an authorization outcome.

The node accepted a leaf resource of `*` for delegated push. Narrowing a wildcard
delegation to the concrete repository happened only in `git-remote-gitlawb`, which
is one client's behaviour and not a protocol boundary: a delegate can sign an
otherwise valid `agent -> node` invocation that keeps `with: "*"`, it passes
attenuation because its proof is `"*"` too, and the node then authorised a push to
any repository owned by the root DID — including repositories created after the
delegation was issued. `docs/RUN-A-NODE.md` promised exactly the opposite, and
named the growth it was preventing, while resting that promise on the helper.
`repo_capability_matches` now requires a concrete `gitlawb://repos/<owner>/<repo>`.
Wildcard delegations still work through the normal path, because the helper
narrows before signing; only a hand-built wildcard leaf is refused.

`honours_the_resource_wildcard_and_repo_admin` asserted the old behaviour, so it
is now `refuses_a_resource_wildcard_and_honours_repo_admin`, and it separates the
two axes: a `*` ACTION on a concrete repository is still fine, since attenuation
bounds it. `a_wildcard_delegation_cannot_reach_a_second_repository` covers the
attack directly.

`gl` discarded the basename of `GITLAWB_KEY`. The shared resolver returns the
configured PEM path, but `gl` kept only its parent and re-appended `identity.pem`,
while the helper opens the configured path. With
`GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI wrote and read
`/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 — and both owner
enforcement and the delegation proof key off that identity. This was mine, from
last round's centralisation: a directory-level contract substituted where callers
needed a key-file-level one. `key_path_for` now serves both, and the seven
production sites that still hardcoded the basename go through it: `identity`
(load/new/backup/restore), `doctor`, `init`, `name` (x2), `node_stake`,
`quickstart` (x2). An explicit `--dir` keeps `<dir>/identity.pem`.

Also:

- `gl ucan import` binds the delegation's audience to the local identity and
  verifies its chain, expiry, and bounded lifetime before storing. It previously
  accepted a token addressed to a different agent, reported success, and left the
  push to fail on proof linkage with nothing pointing back.

- `build_invocation` selects only a capability the node can authorise. It took the
  first push-class capability, including a constrained one, while the node refuses
  constrained leaves outright — so a delegation holding both a constrained and an
  unconstrained grant succeeded or failed purely on their order in `att`.

- Delegation files are replaced atomically: written to a sibling temp at 0600,
  fsynced, then renamed. `truncate(true)` on the live path emptied a working
  delegation before the replacement was written, so an interruption left the
  helper with an unreadable token and pushes that silently drop `X-Ucan`.

- `gl quickstart` no longer reports "UCAN saved to ..." when the node returned no
  token. Both reviewers flagged this independently; it is the same false success
  fixed in `register` last round, one file over.

Every fix was verified by mutation: restoring the node's wildcard acceptance,
restoring `<parent>/identity.pem`, disabling the audience check, and dropping the
helper's constraint filter each turn the matching test red.
2026-08-22 14:22:01 +05:30
Vasanthdev2004 fad75a6579 fix(gl,core): close round five — lazy home, envelope reads, honest register
Five findings from the round-five review, four of them introduced by round four's
own centralisation.

resolve_key_path and resolve_identity_dir opened with `home_dir()?`, but
identity_key_path only needs a home when the key is unset, empty, or `~/`-prefixed.
On a host where `dirs::home_dir()` returns None — no HOME and no passwd entry, an
ordinary container shape — a perfectly valid absolute GITLAWB_KEY was discarded and
the push went out unsigned, blaming the home directory for a setting the operator
had got right. The pre-round code resolved an absolute key without consulting home
at all, so this was a regression, not an inherited gap. The core resolvers now take
`Option<&Path>` and demand a home only where the value being resolved needs one.

`gl ucan show` called Ucan::decode on the whole of ucan.json, but register, init,
and quickstart all write an envelope — {"ucan", "node", "did", "saved_at"} — and
doctor and quickstart already read it as one. Ucan is {payload, s}, so show failed
with "missing field `payload`" immediately after a successful `gl register`. It now
reads the envelope like every other reader, and still accepts a bare token so files
written by an older gl stay readable. The predating mismatch belongs to this round
because this round rerouted cmd_show and claimed to unify the identity flow.

`gl register` printed "You are now a verified agent" even when the node returned no
bootstrap UCAN — the exact shape the previous commit message said it existed to
remove, one line below the fix. The closing line is now inside the branch that
actually stored a token.

The MCP `ucan_show` tool called gitlawb_dir(None) while every sibling tool in
call_tool honours the server's --dir, so one session read two identity directories.
The parameter was already in scope.

Six `--dir` help texts still promised "default: ~/.gitlawb" after the resolver
stopped having that default.

Both behavioural fixes were verified by mutation: demanding a home unconditionally
turns an_absolute_key_resolves_without_a_home_directory red, and dropping the
envelope branch turns the_register_envelope_decodes red.
2026-08-18 15:28:10 +05:30
Vasanthdev2004 75aa1a0a4e fix(gl): finish the identity-path sweep on the write side
The previous commit unified where `gl` READS the identity from and stopped
there. Four resolvers were left holding their own `~/.gitlawb`, and one of them
is a write:

  register.rs   ucan_path        the bootstrap UCAN from POST /api/register
  quickstart.rs the wizard dir   identity generation AND the UCAN it stores
  name.rs       identity_dir     reads identity.pem
  node_stake.rs load_did         reads identity.pem

`register.rs` is the one that actually breaks a working setup. With
`GITLAWB_KEY=/data/keys/identity.pem` — the shape `.env.example` documents —
`gl register` loaded the key from `/data/keys/identity.pem` and then wrote
`ucan.json` to `~/.gitlawb/`. Registration reported success; `gl doctor`,
`gl ucan show`, `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the
key's directory, found nothing, and reported an unregistered identity. Split
storage, no error anywhere.

`quickstart` had the same shape end to end: it generates the identity and stores
the bootstrap token, so an operator who ran the wizard under `GITLAWB_KEY` got
both in a directory nothing else consults.

`name.rs` and `node_stake.rs` only read, but both fell back to `PathBuf::from(".")`
when the home directory could not be determined, which makes the identity path
depend on the working directory — the same defect the helper's `resolve_key_path`
carried before it moved to the shared resolver.

All four now go through `crate::identity::gitlawb_dir`. Afterwards the only
`dirs::home_dir()` calls left in `gl` are the one inside `gitlawb_dir` itself,
one test expectation, and `doctor`'s shell-rc scan, which is unrelated to the
identity directory.

`gl register` also no longer prints a path it did not use: the success line was
hardcoded to `~/.gitlawb/ucan.json`, so an operator with `GITLAWB_KEY` set was
told to look somewhere the file was not. It now prints the real destination, and
says so plainly when the node returned no token at all.

The `GITLAWB_KEY` test guard moves to `identity::test_env` so the resolver's own
cases and the new register case share one lock. Two suites each holding their
own mutex over the same process-global variable would not serialise against each
other.

Verified by reverting `ucan_path` to the hardcoded form and watching
`register_saves_the_bootstrap_ucan_beside_the_key` fail on exactly that
assertion.
2026-08-16 21:11:08 +05:30
Vasanthdev2004 05fc52db53 test(git-remote): cover delegation_header end to end
`delegation_header` was the one piece of the delegated push path with no test.
Its parts each had one — `split_pack_post_url`, `build_invocation`,
`delegation_path` — but nothing checked they compose, and this is the function
where a regression is silent by construction: every failure inside it returns
`None` and the push goes out without `X-Ucan`, so a break surfaces as a 403 at
the node with nothing locally to connect it to.

Five cases, driven against a mockito node with the store seeded where
`gl ucan import` would have left it:

- the delegated push: a stored token becomes an invocation issued by the agent,
  addressed to the node's DID, rooted at the repo owner, with every link bounded
- the owner's own push: no store read, no node round-trip. The comparison has to
  survive the form mismatch — the keypair holds `did:key:z…` while the URL
  carries the bare key — so the mock asserts zero hits
- no usable delegation: an empty store, a token that does not decode, and a
  `git/fetch` capability that carries nothing to wrap. All three yield no header
  rather than an error
- an unreadable node DID: a 500, a JSON body with no `did`, a proxy's HTML error
  page, and a `did` that does not parse. The push loses its header, never aborts
- a path-prefixed node base: the DID probe must go to `/gitlawb`, not `/`.
  `split_pack_post_url` is unit-tested for the prefix, but nothing checked the
  probe followed it; the mock on `/` asserts zero hits

Each case was watched failing before it was kept, against five separate
mutations: the owner short-circuit removed, the prefix dropped from the node
base, `build_invocation`'s push-class filter widened, the node-DID lookup given
a fallback, and the invocation addressed to the agent instead of the node.
2026-08-16 20:43:22 +05:30
Vasanthdev2004 e15fa73466 fix(identity): one resolver for GITLAWB_KEY across gl and the helper
`gl` and `git-remote-gitlawb` each carried their own answer to "where is my
identity?", and so did five call sites inside `gl` itself. The round-three fix
hardened one of them — `gitlawb_dir` — and left the rest, which is why the same
misconfiguration kept surfacing in a new place each round.

The rules now live in `gitlawb-core::identity_path`, the crate both binaries
already depend on:

  identity_key_path()  $GITLAWB_KEY, else ~/.gitlawb/identity.pem
  identity_dir()       its parent — the delegation store

read through `var_os` (so a non-UTF-8 path is refused rather than folded into
"unset" by `var`), with `~/` expanded on the first path component, a relative
value refused, and a bare `~`, `~/`, or `/` refused rather than resolved to a
directory whose parent is not where anything lives.

Both take the home directory as an argument rather than looking it up: core is
held to an explicit dependency allowlist and both callers already carry `dirs`,
so the rules can be shared without widening core's tree. It also lets every
case be tested against a fixed home instead of the machine's.

Call sites moved onto it:

  gl   identity::gitlawb_dir            delegates to identity_dir
  gl   identity::load_keypair_from_dir  was ~/.gitlawb even after `gl identity
                                        new` wrote elsewhere, so `gl ucan
                                        delegate` signed with a stale DID — or
                                        found nothing — for exactly the
                                        operators who moved their key. Every
                                        `load_keypair_from_dir(None)` caller
                                        (register, repo, pr, clone, mcp) is
                                        fixed with it.
  gl   doctor::run                      the one command whose job is to explain
                                        a broken setup was reporting on a
                                        directory the setup does not use
  gl   init (ucan.json, generate_identity)
  gl   mcp ucan_show
  gl   ucan_cmd::cmd_show
  helper resolve_key_path / the delegation store behind delegation_header

The helper's version was the loosest of the set: `env::var`, a literal `"~/"`
prefix, `HOME` falling back to `"."` (never set on Windows), and no absolute
check at all.

Also in this commit:

- `gl ucan import` creates the store and the token at their final mode.
  `create_dir_all` then chmod leaves 0755 under the usual umask, and
  `fs::write` then chmod leaves 0644, both readable by any local user until the
  second call lands. `DirBuilder::mode` and `OpenOptions::mode` close the
  window; the trailing `set_permissions` now only matters for a store an older
  `gl` left behind.

- `gl ucan import` refuses a delegation the push path cannot use. It filtered on
  resource shape only, so a `pr/open` token printed "Stored delegation for
  owner/repo" and was then dropped by `build_invocation` behind a
  `tracing::warn`, surfacing as a 403 with nothing connecting it to the earlier
  success. Import now applies the same push-class filter the helper does.

- README's write-authorization limitation is narrowed to what is actually
  missing (revocation, `nb` interpretation, non-push capabilities) rather than
  claiming delegated push is not implemented.

Every new guard was verified by disabling it and watching the matching test go
red: the absolute-path check, the bare-`~` refusal, the `load_keypair_from_dir`
routing, and the import action filter each own a failing test.
2026-08-16 20:43:22 +05:30
Vasanthdev2004 5dfd8c5874 fix(gl): refuse a relative or non-UTF-8 GITLAWB_KEY when deriving the store
Two defects in the previous round's fix, both of which put the delegation
somewhere the helper does not look and end as an unexplained 403.

A relative GITLAWB_KEY resolved differently in each half. `gl ucan import` and
`git-remote-gitlawb` do not share a working directory, so `keys/identity.pem`
sends the import to one `delegations` directory and the lookup to another. A
one-component value is worse: `parent()` yields "", so the store becomes
`./delegations` relative to whatever directory happened to be current. It is now
refused with a message that says why, rather than silently resolved.

`std::env::var` returns Err for a non-UTF-8 value, which the code treated as
unset — indistinguishable from having no GITLAWB_KEY at all, and silently
selecting ~/.gitlawb instead of the operator's real key directory. `var_os`
keeps the OsString, so a valid non-UTF-8 path now works and an empty one is
still treated as unset.
2026-08-16 20:42:35 +05:30
Vasanthdev2004 5071524c2c fix(gl,git-remote): make a delegation issued the documented way actually work
Two silent failures, both of which end as a 403 telling the delegate to obtain
the delegation they are already holding.

The narrowing fix broke the documented issuing form
----------------------------------------------------
`build_invocation` built `gitlawb://repos/{owner}/{repo}` from the push URL and
compared it to the delegation's `with` with `==`. The URL always carries the BARE
owner, since `parse_gitlawb_url` takes the last colon-delimited segment, while
`docs/RUN-A-NODE.md` tells the owner to issue
`--cap gitlawb://repos/<owner-did>/<repo>` — the full DID. Those strings never
match, `find` returns None, and because every failure in `delegation_header` is
best-effort the push goes out with no `X-Ucan`.

This was introduced by the previous round: copying `att` through unchanged worked
because the node normalizes both forms in `did_matches`. Narrowing to a
URL-derived string did not.

The owner segment is now compared on the bare key, and the parent's `with` is
kept VERBATIM whenever it already names this repo — `is_attenuated_by` compares
`with` by exact equality, so re-emitting the bare form under a full-DID parent
would fail attenuation at the node and trade one silent refusal for another. Only
a `*` parent uses the URL-derived resource, which is the case narrowing exists
for. Both combinations are now tested; neither side exercised them before.

The two halves disagreed about where delegations live
------------------------------------------------------
`git-remote-gitlawb` resolves its store from `resolve_key_path().parent()`, which
honors `GITLAWB_KEY`. `gl ucan import` wrote under `gitlawb_dir(None)`, which was
always `~/.gitlawb`. With `GITLAWB_KEY=/data/keys/identity.pem` — the shape
`.env.example` documents — the import stored the token in one directory and the
helper read another, empty one.

`gitlawb_dir` now falls back to the parent of `GITLAWB_KEY` before `~/.gitlawb`,
so both halves derive the store from the same setting.

Also: `.env.example` still said a push from a non-owner DID is rejected, which is
the behaviour this branch removes.
2026-08-16 20:42:35 +05:30
Vasanthdev2004 d503655f90 fix(gl): restrict the delegations directory, and state the contract
The imported delegation was written 0600 on Unix and left to inherit the
directory ACL elsewhere. Tightening only that one file would have been theatre:
identity.pem sits in the same directory under exactly the same assumption, and
its disclosure is strictly worse.

The directory itself is now 0700 on Unix, and the comment states the contract
that already governed the private key — gitlawb_dir accepts any path, std::fs
has no portable ACL API, so on other targets the caller must supply a
user-private directory, which a per-user profile provides by default.
2026-08-16 20:42:35 +05:30
Vasanthdev2004 a2502fcd87 fix(core,node,gl,git-remote): close the second review round on delegated push
Each finding was reproduced against the code before being changed; two were
confirmed with the mutation the reviewer described.

A delegation could be perpetual
--------------------------------
`exp` is optional, `is_expired` reports false when it is absent, `gl ucan
delegate` defaulted to no expiry, and there is no revocation path. So the
default flow minted a permanent push grant: once the token leaked, the owner's
only remedy was rotating the DID the repo is keyed on. The PR body claimed the
damage window was the token's `exp`, which was simply untrue when `exp` was
`None`.

`Ucan::chain_lifetime_is_bounded` walks every link, and `ucan_grants_push`
requires it. It is deliberately NOT enforced inside `verify_chain`: an
unbounded token is well-formed and may suit a read-only capability; whether an
unbounded grant is acceptable is the consumer's policy, not the format's.
`gl ucan delegate` now defaults to 720 hours with an explicit `--no-expiry`
opt-out, and the helper carries the delegation's expiry onto the invocation so
the leaf is bounded too.

The recursion to the root was untested
---------------------------------------
Every chain in the suite was depth two, where the immediate proof IS the root,
so nothing distinguished walking to the true root from returning the proof's
issuer. Confirmed by mutation: keeping full recursive validation but returning
`proof.payload.iss` left gitlawb-core at 92 passed, the node's UCAN tests at 17,
and the e2e green. A three-link owner -> lead -> agent test now pins it, with
`assert_ne!` against the middle issuer as well as `assert_eq!` against the root
— without the former, returning the middle would still pass.

A path-prefixed node base broke delegated push entirely
--------------------------------------------------------
`GITLAWB_NODE` may carry a path (`https://host/gitlawb` behind a proxy), and
`repo_base` passes it through verbatim. `split_pack_post_url` read the FIRST two
segments as owner/repo, so the prefix became the owner: the delegation lookup
missed, the DID probe hit the wrong URL, no `X-Ucan` was sent, and a valid
delegate got a 403 — silently, because every failure on that path is
best-effort. It now strips the known trailing `<owner>/<repo>/<service>` instead,
which is correct at any prefix depth, and prefix segments carry the same
allow-list so a `..` cannot redirect the probe.

A wildcard delegation grew without limit
-----------------------------------------
`with: "*"` covered every repo the owner created AFTER signing, a scope nobody
chose. `build_invocation` now narrows to the concrete repo, which
`is_attenuated_by` accepts under a `*` parent, so a captured invocation is worth
one repo rather than all of them. Constraints are copied from the covering
capability rather than dropped, since dropping them is a widening.

Branch protection and the owner gate now disagree, on purpose
--------------------------------------------------------------
A delegate clears the owner gate and is still refused on a protected branch. That
is the intended policy: a protected branch is the owner's explicit marker that
even routine writes should stop, and if a delegation overrode it, issuing any
capability would weaken every protection already set. The comment claiming
non-owners never reach that loop is corrected, and
`delegated_push_is_still_refused_on_a_protected_branch` pins it — asserting the
body names the branch, so the refusal is provably branch protection rather than
the owner gate.

Smaller items
-------------
The denial body no longer says "only the repo owner may push", which stopped
being true once a delegation could authorize one. It stays a single unconditional
message: varying it by whether a delegation was presented, expired, or named
another repo would turn the refusal into an oracle for which capabilities exist.

`gl ucan import` writes the delegation 0600, matching the sibling identity key.
The token alone cannot push — the node requires `iss` to equal the request
signer — but it discloses the delegation graph.

`docs/RUN-A-NODE.md` told operators not to enable owner-push until every pusher
was the owner, which is the workflow this change adds. It now documents the
delegation flow, the four requirements the node enforces and why, that a
delegation does not override branch protection, and that withdrawal is by expiry
only. `README.md` no longer describes UCAN as a future workflow.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 f455726ef3 fix(core,gl,git-remote): close two review findings on delegated push
Both are mine, both were found in review, and both are load-bearing.

1. Stripping `nb` mid-chain escalated a constrained delegation.

`Capability::is_attenuated_by` compared only `with` and `can`, so the holder of
a capability constrained by `nb` could re-delegate the same resource and action
with the constraints removed and `verify_chain` still accepted the chain. The
node's push gate refuses constrained capabilities at the LEAF, so it then saw an
unconstrained one and granted repo-wide push — guarding the leaf guarded the
wrong end of the chain.

Constraints now participate in attenuation: an unconstrained parent permits
anything, an identical child is attenuated, and both a differing child and a
child that drops the constraints are refused. `nb` has no interpreted semantics
yet, so "different" cannot be shown to be narrower and is refused with the
stripping case. Tests cover the forged strip, the legal unchanged case, and
adding constraints under an unconstrained parent.

2. `gl ucan import` wrote to a path taken from an untrusted token.

`repo_from_resource` split the resource with `rsplit_once('/')`, so the owner
half could carry separators, `..`, or an absolute prefix. `Path::join` with an
absolute component discards the base entirely, so a resource of
`gitlawb://repos/C:/Windows/System32/x` did not merely climb out of the
delegations directory — it replaced it. The value then reached `std::fs::write`.

The resource must now be exactly `gitlawb://repos/<owner>/<repo>`, with each
half an allow-listed component: alphanumerics plus `.`, `-`, `_` and `:` (a DID
carries colons), never `.` or `..` or anything containing `..`. An allow-list
rather than a deny-list of separators, which would miss whichever ones the next
platform introduces.

The same shape existed in the helper's `split_pack_post_url`. There the value
comes from the remote URL and the path is only read, but a traversing owner
would read an arbitrary file and send its contents to the node as `X-Ucan`, so
it carries the same guard.

Also: the node-DID probe now uses a 5s timeout instead of inheriting the shared
client's 300s. It is best-effort metadata, and a stalled node should not delay
every delegated push by five minutes before falling back to sending no header.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 a9e9c4813c build: sync Cargo.lock for the git-remote-gitlawb dependencies
Adding serde_json and chrono to crates/git-remote-gitlawb/Cargo.toml updated
Cargo.lock locally, but the lockfile lives at the repo root and was never
staged. Every CI job builds with --locked, so all seven compiling jobs failed
immediately with "cannot update the lock file ... because --locked was passed".
2026-08-16 20:42:34 +05:30
Vasanthdev2004 8843c4ffdf test(git-remote): cover pack-POST URL splitting
split_pack_post_url decides which repo a delegation is looked up for, so a
parsing slip silently means no delegation was found, surfacing as a confusing
403 rather than a visible error. Covers the optional .git suffix, a repo
genuinely named x.git, and the malformed shapes that must not parse.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 3d81f578ee feat(git-remote): send a delegated push capability as X-Ucan
Wraps a stored delegation into an invocation (iss=agent, aud=node,
prf=[delegation]) and attaches it to the receive-pack POST, which is what lets a
CI or delegated key push once owner-only push is enforced. Only on the push: a
fetch is gated by read visibility, not by git/push.

Capabilities are copied from the delegation unchanged, so an invocation can
never be broader than what was delegated. No expiry is set on the invocation
itself — the delegation`s own exp still bounds the chain because verify_chain
checks each proof`s expiry as it recurses. A test proves that rather than
asserting it: an already-expired delegation still fails the chain after
wrapping, so an expired grant cannot be laundered into an open-ended one.

The whole path is best-effort. A missing delegation, an unreachable node, or an
unreadable stored token all send the request without the header. The node
decides whether one was required, and a node denial has to reach the user
instead of being pre-empted by a local guess.

delegation_path duplicates gl`s six-line version because gl is not a dependency
of this crate; both carry a pointer to the other.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 d141e59dc2 feat(gl): gl ucan import stores a delegation for the push helper
The git remote helper needs a delegation on disk in a location it can derive
from a gitlawb:// URL alone. Files key on the bare base58 owner key because
did:key contains a colon, which Windows will not accept in a filename, and the
same identity appears in both full and bare form across this codebase — storing
under one and looking up by the other would silently miss.

Import decodes the token so a malformed delegation fails here, where the error
is actionable, rather than surfacing as an unexplained 403 in the middle of a
git push. A delegation naming no repository is rejected with the resources it
did carry, since that is almost always a wrong --cap argument.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 d27f0bec1b feat(node): honour a delegated git/push capability on the push path
caller_authorized_to_push becomes owner || delegated, exactly the Phase 2 its
own doc comment described. The owner check is unconditional and runs first, so
the UCAN path can only ever turn a 403 into a 200, never the reverse. A push
carrying no X-Ucan reaches the same owner-only decision it did before.

git_receive_pack takes the verified token as Option<Extension<VerifiedUcan>>;
axum extracts an absent extension as None rather than rejecting, so the optional
header stays optional.

The denial message is deliberately identical whether a delegation was absent,
expired, or named a different repository. A behavioural test asserts the two
bodies are byte-identical, because a difference would turn the refusal into an
oracle for which capabilities exist.

The behavioural test drives both auth layers with a real RFC 9421 signature and
a real invocation, and discriminates on status: 500 means the request passed
require_signature, passed require_ucan_chain, cleared the owner gate, and
reached git on a repo with no disk backing. A bare `!= 403` would let a 401
regression through. It needs no fake-git shim, so unlike the rest of the push
path it is not cfg(unix) and runs on every platform.

Both new tests were verified to fail: with the delegation branch removed the
behavioural test reports 403 where it wants 500, and the unit test's assertion
fires. A test written after its implementation proves nothing until it has been
watched failing.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 f8b9cf11ea feat(node): add ucan_grants_push, anchored at the repo owner
The predicate that decides whether a delegated capability authorizes a push.
The chain must root at the repo owner — data the node holds independently of
the token, which is what a self-minted chain cannot forge — and the leaf must
carry git/push for this repo.

Only the leaf is examined for the capability. verify_chain has already
established that each leaf capability is attenuated by its proof, transitively
to the root, so a surviving leaf capability is no broader than what the root
granted; re-walking the chain would duplicate that guarantee.

Resource matching is structural rather than a string compare because owner_did
is stored full on canonical rows and bare on mirror rows; a literal match would
deny valid delegations for every mirror, which is a defect that would have
looked like a permissions bug rather than a parsing one.

A capability carrying nb authorizes nothing while constraints stay
uninterpreted, so an owner who writes a ref restriction never accidentally
grants repo-wide push.

Not yet wired into the push path — the next commit does that — so a non-test
build still reports VerifiedUcan::ucan as unread.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 1b1c972654 feat(node): carry the verified UCAN and its root into the request
require_ucan_chain validated a presented token and discarded it, so no handler
could read the result and Ucan::can had no call site anywhere in the node. A
UCAN could only ever fail a request, never authorize one.

The middleware's own behaviour is unchanged — an absent header passes through,
an invalid chain is still 401. It now also parks the verified token and the root
issuer its chain rests on in request extensions, so an authorization decision
downstream can use them. The root is stored rather than recomputed so the chain
is walked once per request.

Holding a VerifiedUcan is deliberately not an authorization decision. did:key is
self-certifying, so a chain that verifies proves only internal consistency; the
caller has to compare the root against an identity it trusts for the resource
being touched. The next commit adds that comparison for git/push.
2026-08-16 20:42:34 +05:30
Vasanthdev2004 13a24240a9 fix(node): gate two Unix-only tests so the crate compiles on Windows
Two tests build a fake git as a `/bin/sh` script, mark it executable through
`std::os::unix::fs::PermissionsExt`, and reap the hung `rev-list` with
`libc::kill(SIGKILL)`. None of that exists on Windows, and neither test was
cfg-gated, so `cargo test -p gitlawb-node` failed to compile there with six
errors before running anything. A Windows checkout could not run a single test
in the crate, including the ones that are platform-independent.

Gate both with `#[cfg(unix)]`. The attribute is a no-op on Linux, so CI keeps
running them exactly as before; only the Windows build changes, from "does not
compile" to "runs the platform-independent tests".

Refs #228
2026-08-16 20:42:34 +05:30
Vasanthdev2004 2a8d64d057 feat(core)!: verify_chain returns the proof chain's root issuer
verify_chain returned Result<()>, so a caller learned that a chain was
internally consistent and nothing else. `did:key` is self-certifying: anyone
can mint a keypair, self-issue `*` on `*`, and produce a token that verifies.
That is correct UCAN behaviour rather than a defect — a token with no proofs is
its own root — and it still verifies after this change. But it does mean "the
chain verifies" is not an authorization answer on its own, and without the root
identity no caller could ask the question that actually decides a push: does
this chain rest on someone this repository trusts?

Returning the root means a caller cannot accept a chain without being handed
the identity it rests on. Callers that legitimately do not care — the node
middleware checking that a bootstrap token is well-formed, for one — discard it
explicitly. The doc comment now states outright that this establishes internal
consistency and not trust, and names what a caller has to compare the root
against.

Multi-proof chains are refused: more than one proof means more than one root,
and nothing says which root authorized a given capability, so returning any
single one would be unsound. The old loop followed every proof but returned
nothing, so a two-proof chain verified silently; the new test pinned that by
observing the pre-fix code accept one. Ucan::delegate only ever writes one
proof, so no token this codebase produces is affected.

BREAKING CHANGE: Ucan::verify_chain returns Result<Did> instead of Result<()>.
2026-08-16 20:42:33 +05:30
28 changed files with 5555 additions and 598 deletions

No files matched your search

+8 -4
View File
@@ -3,7 +3,10 @@
# All variables are optional unless marked REQUIRED.
# ── Node identity ─────────────────────────────────────────────────────────
# Path to the node's Ed25519 keypair PEM file.
# Path to the node's Ed25519 keypair PEM file. Must be absolute: its parent is
# also the delegation store that `gl ucan import` writes and `git-remote-gitlawb`
# reads, and the two do not share a working directory, so a relative path sends
# them to different places. `~/...` is expanded; a bare `~` is not accepted.
# Generate with: gl identity new
GITLAWB_KEY=/data/keys/identity.pem
@@ -98,9 +101,10 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# push to every repository, private ones included. On by default.
#
# Set to false only for a rolling upgrade whose pushers are not yet the repo
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
# verified but not yet honored for authorization, so they cannot push while this
# is on. See docs/RUN-A-NODE.md.
# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that
# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and
# importing one. A CI key with no such delegation counts as a non-owner and
# cannot push.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
Generated
+3
View File
@@ -3377,10 +3377,13 @@ name = "git-remote-gitlawb"
version = "0.7.1"
dependencies = [
"anyhow",
"chrono",
"dirs",
"gitlawb-core",
"libc",
"mockito",
"reqwest",
"serde_json",
"tempfile",
"tracing",
"tracing-subscriber",
+5 -10
View File
@@ -66,8 +66,8 @@ Good today:
Known limitations:
- Repository write authorization is not secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility, so a valid HTTP Signature identifies a pusher but does not enforce owner-only pushes.
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, on by default since #330. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere.
- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
@@ -188,12 +188,7 @@ 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, 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.
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
### Full lifecycle against an iCaptcha-enforcing node
@@ -316,7 +311,7 @@ metadata local disk / optional S3
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| UCAN | Delegation token for future capability-based workflows. |
| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
@@ -398,7 +393,7 @@ Important node settings:
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
+9
View File
@@ -14,8 +14,17 @@ path = "src/main.rs"
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
anyhow = { workspace = true }
reqwest = { workspace = true }
# Reading the node's DID from `GET /` so a delegated push can address its
# invocation to the right executor.
serde_json = { workspace = true }
# The invocation carries the delegation's expiry, so an unbounded write capability
# is never minted; converting the stored i64 timestamp needs chrono.
chrono = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core,
# which takes `home` as an argument so core's dependency allowlist stays lean.
dirs = "5"
[dev-dependencies]
mockito = "1"
File diff suppressed because it is too large. Load diff
@@ -267,15 +267,6 @@ 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") {
@@ -298,14 +289,7 @@ fn fetch_with_helper_env(
// 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")
// 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);
}
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-fetch");
run_bounded(cmd, Duration::from_secs(30))
}
@@ -1258,71 +1242,3 @@ 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)
);
}
+299
View File
@@ -0,0 +1,299 @@
//! Where the identity key and the delegation store live.
//!
//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a
//! delegation to `<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}"
);
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod identity;
pub mod identity_path;
// `url` is the one dependency here that drags a tail (idna, then the icu
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
// needs this predicate already parses URLs, so they opt in and nothing else
+20
View File
@@ -30,10 +30,30 @@ pub fn is_bidi_format(c: char) -> bool {
)
}
/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs
/// ANSI and OSC escapes) and every bidi/format control that
/// [`is_bidi_format`] names. What remains is safe to write to a terminal.
///
/// Length is not this function's business: a caller that shows the text
/// decides how much of it to show.
pub fn strip_terminal_controls(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !is_bidi_format(*c))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_removes_escapes_and_reordering_but_keeps_text() {
let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}";
let clean = strip_terminal_controls(hostile);
assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}");
assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c)));
}
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
+741 -29
View File
@@ -16,6 +16,19 @@ use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// Proof chains deeper than this fail closed, in every walk over `prf`:
/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and
/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is
/// longer than owner → agent → node. The bound exists for the hand-built token:
/// the node verifies the chain on the `X-Ucan` header of any signed request, and
/// nothing else says how deep a sender may nest `prf`. In practice not very
/// deep — a proof is a JSON string inside the next link's JSON, so each level
/// re-escapes the one beneath it and the encoding roughly doubles per link
/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should
/// refuse on its own terms rather than count on the encoding running the sender
/// out of room first.
pub const MAX_CHAIN_DEPTH: usize = 8;
/// A UCAN capability: what resource the token grants access to.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Capability {
@@ -49,11 +62,32 @@ impl Capability {
/// action field and `repo/admin` in the parent's action position act as
/// wildcards that cover any delegated value; wildcards on `self` carry no
/// special meaning.
///
/// Constraints (`nb`) participate, and conservatively:
///
/// | parent | child | verdict |
/// |---|---|---|
/// | none | anything | attenuated — adding constraints narrows |
/// | some | identical | attenuated |
/// | some | different | refused — narrowing is unprovable without semantics |
/// | some | none | refused — dropping constraints widens |
///
/// The last row is the one that matters. Ignoring `nb` here let a holder of a
/// constrained capability re-delegate the same resource and action with the
/// constraints removed, and the chain still verified — so a consumer that
/// refuses constrained capabilities at the leaf saw an unconstrained one and
/// granted it. Since `nb` has no interpreted semantics yet, "different" cannot
/// be shown to be narrower and is refused with it.
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
let resource_ok = parent.with == self.with || parent.with == "*";
let action_ok =
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
resource_ok && action_ok
let constraints_ok = match (&parent.constraints, &self.constraints) {
(None, _) => true,
(Some(p), Some(c)) => p == c,
(Some(_), None) => false,
};
resource_ok && action_ok && constraints_ok
}
}
@@ -71,6 +105,138 @@ pub mod caps {
pub const REPO_ADMIN: &str = "repo/admin";
}
/// Rules shared by every boundary that decides whether a UCAN can push.
///
/// Four places answer "can this token push to this repository?": `gl ucan
/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan
/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation,
/// and the node when it authorizes the push. For three review rounds each of them
/// carried its own copy of the answer, and every round found a token that one
/// boundary accepted and the next refused: a constrained grant that imported and
/// then authorized nothing; a wildcard proof the helper narrowed and the node
/// rejected; a resource whose owner the leaf named but the chain root did not.
///
/// The rules live here, once, in the crate all four already depend on. A boundary
/// may add checks of its own — the node anchors the chain root to a repository
/// record it holds independently, import binds the audience to the local key —
/// but the definition of a *usable push capability* is not one of them.
pub mod push {
use super::{caps, Capability, Ucan};
/// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are
/// the same identity. Mirror rows store the bare form; canonical rows and
/// every token store the full form.
///
/// Collapses representation only within `did:key`. `did:web` and
/// `did:gitlawb` share the base58 space, so a trailing-segment compare would
/// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix,
/// a value that still contains `:` is a non-key DID and matches nothing bare.
pub fn did_key_eq(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
!ka.contains(':') && !kb.contains(':') && ka == kb
}
/// The actions that authorize a push: `git/push` itself, the action wildcard,
/// and `repo/admin`, which covers it.
pub fn is_push_action(can: &str) -> bool {
can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN
}
/// `gitlawb://repos/<owner>/<repo>` → `(owner, repo)`, or `None` for any other
/// shape. Exactly two non-empty segments: an owner DID never contains `/`, so
/// a third segment, a trailing slash, or an empty half is malformed rather than
/// something to interpret.
pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> {
let rest = with.strip_prefix("gitlawb://repos/")?;
let (owner, repo) = rest.split_once('/')?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return None;
}
Some((owner, repo))
}
/// A push-class capability whose resource is the wildcard. Refused at
/// issuance, at import, by the helper, and by the node: a delegation's scope is
/// fixed when it is issued, and `*` cannot say which repositories it covered
/// at that moment.
pub fn is_push_wildcard(with: &str, can: &str) -> bool {
with == "*" && is_push_action(can)
}
pub use super::MAX_CHAIN_DEPTH;
impl Capability {
/// Push-class, unconstrained, and naming exactly this repository.
///
/// Unconstrained because `nb` has no semantics yet: an owner who wrote
/// constraints meant to restrict, and honouring the capability while
/// ignoring them would grant more than was intended. The owner segment is
/// compared with [`did_key_eq`], so a token issued against the full DID
/// matches a mirror row keyed on the bare form.
pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool {
self.constraints.is_none()
&& is_push_action(&self.can)
&& parse_repo_resource(&self.with)
.is_some_and(|(o, r)| did_key_eq(o, owner) && r == repo)
}
}
impl Ucan {
/// Whether this chain — the leaf **and every proof behind it** — carries a
/// capability that [`Capability::grants_push_to`] this repository.
///
/// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts
/// a concrete child under a `*` parent, so a leaf that names the repository
/// can sit on a proof that names every repository the root owns — including
/// ones created after the delegation was issued. Checking the leaf alone
/// let exactly that through.
///
/// This establishes scope, not trust. It does not verify signatures,
/// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the
/// root it returns to something held independently of the token.
///
/// Fails closed on anything it cannot vouch for: a proof that does not
/// decode, more than one proof per link, or a chain deeper than
/// [`MAX_CHAIN_DEPTH`].
pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool {
self.chain_grants_push_to_at(owner, repo, 0)
}
fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool {
if depth >= MAX_CHAIN_DEPTH {
return false;
}
if !self
.payload
.att
.iter()
.any(|c| c.grants_push_to(owner, repo))
{
return false;
}
// `verify_chain` refuses more than one proof per link for the same
// reason: two proofs mean two roots, and nothing says which authorized
// what.
if self.payload.prf.len() > 1 {
return false;
}
match self.payload.prf.first() {
None => true,
Some(token) => match Ucan::decode(token) {
Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1),
Err(_) => false,
},
}
}
}
}
/// The UCAN payload (what gets signed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UcanPayload {
@@ -146,6 +312,37 @@ impl Ucan {
}
}
/// Whether every link in this chain carries a finite `exp`.
///
/// `exp` is optional in the format, and [`Self::is_expired`] reports `false`
/// when it is absent — so a link without one never expires. With no revocation
/// mechanism, a chain containing such a link is a permanent grant: a leaked
/// token cannot be withdrawn, and the issuer's only remedy is to rotate the
/// identity the resource is keyed on.
///
/// Consumers that turn a UCAN into write authority should require this. It is
/// deliberately not enforced inside [`Self::verify_chain`], because a
/// non-expiring token is well-formed and may be perfectly appropriate for a
/// read-only or advisory capability; whether an unbounded grant is acceptable
/// is the consumer's policy, not the format's.
///
/// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`.
pub fn chain_lifetime_is_bounded(&self) -> bool {
self.chain_lifetime_is_bounded_at(0)
}
fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool {
if depth >= MAX_CHAIN_DEPTH {
return false;
}
if self.payload.exp.is_none() {
return false;
}
self.payload.prf.iter().all(|token| {
Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded_at(depth + 1))
})
}
/// Check if this UCAN's not-before time is in the future (token not yet valid).
pub fn is_before_valid(&self) -> bool {
if let Some(nbf) = self.payload.nbf {
@@ -248,8 +445,33 @@ impl Ucan {
/// 3. Check the proof is not expired
/// 4. Recursively verify the proof's own chain
///
/// A UCAN with no proofs (root capability) passes trivially.
pub fn verify_chain(&self) -> Result<()> {
/// A UCAN with no proofs is its own root, so it returns its own issuer.
///
/// **This establishes internal consistency, not trust.** `did:key` is
/// self-certifying, so anyone can mint a keypair and produce a chain that
/// verifies. A caller making an authorization decision MUST compare the
/// returned root against an identity it trusts for some reason outside this
/// token — a repo owner, a configured value, a registry lookup. Discarding
/// the return value is only correct when the caller is checking that a token
/// is well-formed and deliberately does not care who issued it.
///
/// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node
/// runs this on the `X-Ucan` header of any signed request before it asks
/// what the chain grants, so this is the first place sender-chosen depth is
/// felt: each link costs a signature check and a decode of everything
/// beneath it. The scope walk was bounded and this one was not, which made
/// that bound decorative — a deep chain met the unbounded walk first.
pub fn verify_chain(&self) -> Result<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"
)));
}
// First verify our own signature
self.verify_signature()?;
@@ -261,34 +483,44 @@ impl Ucan {
return Err(Error::Ucan("token is not yet valid".to_string()));
}
for proof_token in &self.payload.prf {
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
// The proof's audience must be this UCAN's issuer
if proof.payload.aud != self.payload.iss {
return Err(Error::Ucan(format!(
"proof chain broken: proof audience {} does not match issuer {}",
proof.payload.aud, self.payload.iss
)));
}
// Every delegated capability must be covered by the proof (attenuation).
for cap in &self.payload.att {
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
if !covered {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
)));
}
}
// Verify the proof's signature and chain recursively
proof.verify_chain()?;
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(),
));
}
Ok(())
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 {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
)));
}
}
// Recurse; the root of the proof's chain is the root of ours.
proof.verify_chain_at(depth + 1)
}
}
@@ -664,4 +896,484 @@ mod tests {
delegated.verify_chain().unwrap();
}
#[test]
fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() {
// owner -> agent (delegation), agent -> node (invocation).
// The root is the owner: that is the identity the whole chain rests on,
// and the only one a caller can meaningfully anchor a trust decision to.
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let delegation =
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
.expect("wrap invocation");
assert_eq!(
invocation.verify_chain().expect("chain must verify"),
owner.did(),
"the root issuer is the owner who started the chain, not the agent presenting it"
);
}
/// A chain is only bounded if EVERY link is. An unbounded link anywhere makes
/// the whole grant permanent, because `is_expired` reports false for it and
/// there is no revocation path to withdraw it.
#[test]
fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let hour = Utc::now() + chrono::Duration::hours(1);
let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue");
let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue");
assert!(
Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"both links finite"
);
assert!(
!Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"the leaf has no expiry, so the grant never lapses"
);
assert!(
!Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root)
.expect("wrap")
.chain_lifetime_is_bounded(),
"a bounded leaf cannot rescue an unbounded proof: the holder can always \
mint a fresh leaf from it"
);
assert!(
!unbounded_root.chain_lifetime_is_bounded(),
"a self-issued token with no expiry is itself unbounded"
);
}
/// A three-link chain: owner -> lead -> agent, which is the real shape of an
/// org delegating to a team lead who delegates to a CI identity.
///
/// Every other chain here is depth two, where the immediate proof IS the root —
/// so nothing distinguishes recursing to the true root from simply returning the
/// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing:
/// without the second, returning the middle issuer would still satisfy a test
/// that only checked "not the leaf".
#[test]
fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() {
let owner = Keypair::generate();
let lead = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead");
let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent");
let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node");
let found = leaf.verify_chain().expect("a three-link chain must verify");
assert_eq!(
found,
owner.did(),
"the root is the owner who started the chain, two hops up"
);
assert_ne!(
found,
lead.did(),
"returning the immediate proof's issuer is not walking to the root"
);
}
#[test]
fn verify_chain_returns_self_as_root_for_a_self_issued_token() {
// A token with no proofs roots at its own issuer. This is what makes a
// self-minted token useless: the caller compares this against the repo
// owner and it will only ever match when the presenter IS the owner.
let agent = Keypair::generate();
let node = Keypair::generate();
let ucan =
Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue");
assert_eq!(
ucan.verify_chain().expect("a root token still verifies"),
agent.did(),
"a self-minted token roots at the minter, however permissive its capabilities"
);
}
/// Stripping `nb` is a widening, and a widening must fail attenuation.
///
/// Without this, a constrained delegation is trivially escalated: the holder
/// re-delegates the same resource and action with the constraints removed,
/// `verify_chain` accepts the chain because attenuation only compared `with`
/// and `can`, and a consumer that refuses constrained capabilities at the leaf
/// (as the node's push gate does) then sees an unconstrained one and grants it.
/// Guarding only the leaf guards the wrong end of the chain.
#[test]
fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }));
let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None)
.expect("issue constrained delegation");
// Same resource, same action, constraints dropped.
let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
let forged =
Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap");
let err = forged
.verify_chain()
.expect_err("dropping the parent's constraints must fail attenuation");
assert!(
err.to_string().contains("attenuation"),
"the failure must name attenuation, got: {err}"
);
}
#[test]
fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] });
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(nb.clone());
let delegation =
Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue");
let invocation =
Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap");
assert_eq!(
invocation
.verify_chain()
.expect("an unchanged constraint must verify"),
owner.did()
);
}
#[test]
fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() {
// Adding `nb` narrows, which is always a legal attenuation.
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue");
let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
.with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] }));
let invocation =
Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap");
assert_eq!(
invocation.verify_chain().expect("narrowing must verify"),
owner.did()
);
}
#[test]
fn verify_chain_rejects_a_multi_proof_chain() {
// Two proofs mean two roots, and nothing says which root authorized a
// given capability. Returning either one would be unsound, so refuse.
let owner_a = Keypair::generate();
let owner_b = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a");
let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b");
// `delegate` only ever writes one proof, so build the two-proof payload by hand.
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: agent.did(),
aud: node.did(),
att: caps_vec,
exp: None,
nbf: None,
prf: vec![
proof_a.encode().expect("encode a"),
proof_b.encode().expect("encode b"),
],
};
let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload");
let s = agent.sign_b64(&signing_bytes);
let multi = Ucan { payload, s };
let err = multi
.verify_chain()
.expect_err("a two-proof chain must be refused");
assert!(
err.to_string().contains("multi-proof"),
"the error must name the reason, got: {err}"
);
}
}
#[cfg(test)]
mod push_scope_tests {
use super::push::*;
use super::{caps, Capability, Ucan};
use crate::identity::Keypair;
fn hour() -> chrono::DateTime<chrono::Utc> {
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());
}
}
+3 -10
View File
@@ -68,16 +68,9 @@ pub(crate) async fn authorize_repo_read(
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
if a == b {
return true;
}
fn key_id(d: &str) -> &str {
d.strip_prefix("did:key:").unwrap_or(d)
}
let (ka, kb) = (key_id(a), key_id(b));
// After stripping `did:key:`, a value still containing ':' is a non-key full
// DID — do not let it match a bare `did:key` id.
!ka.contains(':') && !kb.contains(':') && ka == kb
// One implementation, shared with `gl` and `git-remote-gitlawb` through core:
// every boundary that compares a DID applies the same collapse rule.
gitlawb_core::ucan::push::did_key_eq(a, b)
}
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
+115 -14
View File
@@ -1861,14 +1861,21 @@ 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) => None,
Some(did) if caller_authorized_to_push(record, did, verified) => None,
// One message for every refusal. It must not say whether a delegation was
// presented, was expired, or named another repository: varying it would turn
// the denial into an oracle for which capabilities exist. It only has to be
// TRUE in all of those cases, which the previous owner-only wording no
// longer was once a delegation could authorize a push.
_ => Some(AppError::Forbidden(
"push rejected — only the repo owner may push to this repository \
"push rejected — you must be the repo owner, or hold a valid \
owner-issued git/push delegation for this repository \
(GITLAWB_ENFORCE_OWNER_PUSH is enabled)"
.into(),
)),
@@ -2008,6 +2015,10 @@ pub async fn git_receive_pack(
State(state): State<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,
@@ -2055,6 +2066,7 @@ pub async fn git_receive_pack(
state.config.enforce_owner_push,
&record,
Some(auth.0.as_str()),
verified.as_ref().map(|Extension(v)| v),
) {
tracing::warn!(
repo = %name,
@@ -2066,9 +2078,17 @@ pub async fn git_receive_pack(
}
// ── Branch protection check ──────────────────────────────────────────
// Uses the same verified identity as the owner-push gate above. (When that
// gate is enabled a non-owner never reaches here; this still applies when it
// is off, gating only the branches an owner has explicitly protected.)
// Uses the same verified identity as the owner-push gate above, but a STRICTER
// predicate: owner-only, deliberately not `caller_authorized_to_push`.
//
// A delegate can therefore clear the gate above and still be refused here. That
// is the intended policy, not an oversight: a protected branch is the owner's
// explicit marker that even routine writes should stop, so a `git/push`
// delegation must not silently override it. Widening this to accept a
// delegation would make every existing protection weaker the moment the owner
// issues any capability.
//
// `delegated_push_is_still_refused_on_a_protected_branch` pins this.
for update in &ref_updates {
// Strip refs/heads/ prefix to get plain branch name
let branch = update
@@ -3623,7 +3643,7 @@ mod tests {
#[test]
fn enforced_allows_owner_full_did() {
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none());
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID), None).is_none());
}
#[test]
@@ -3631,36 +3651,92 @@ mod tests {
// Owners are accepted in bare-multibase form, matching the rest of the
// codebase's owner comparisons.
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).is_none());
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT), None).is_none());
}
#[test]
fn enforced_rejects_non_owner_with_forbidden() {
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID)));
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
}
#[test]
fn enforced_rejects_missing_did_with_forbidden() {
// Fail closed: an absent authenticated identity is rejected, not allowed.
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, None));
assert_forbidden(owner_push_rejection(true, &repo, None, None));
}
#[test]
fn disabled_allows_non_owner_and_missing_did() {
// Flag off → legacy behavior: authentication-only, no owner gate.
let repo = repo_owned_by(OWNER_DID);
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none());
assert!(owner_push_rejection(false, &repo, None).is_none());
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID), None).is_none());
assert!(owner_push_rejection(false, &repo, None, None).is_none());
}
/// Build a VerifiedUcan whose chain roots at `root_did` and which carries
/// `git/push` for `repo`. The token's own issuer and audience do not matter
/// here: the middleware has already bound them before this gate is reached.
fn push_delegation(root_did: &str, repo: &crate::db::RepoRecord) -> crate::auth::VerifiedUcan {
let agent = gitlawb_core::identity::Keypair::generate();
let node = gitlawb_core::identity::Keypair::generate();
let ucan = gitlawb_core::ucan::Ucan::issue(
&agent,
node.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{}/{}", repo.owner_did, repo.name),
gitlawb_core::ucan::caps::GIT_PUSH,
)],
// Finite: a write capability that never lapses is refused, since there
// is no revocation path to withdraw a leaked one.
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
)
.expect("issue delegation");
crate::auth::VerifiedUcan {
ucan,
root: root_did.parse().expect("root DID must parse"),
}
}
#[test]
fn enforced_allows_a_non_owner_holding_an_owner_rooted_push_capability() {
// The regression owner-only push introduced: a CI or delegated key with a
// valid capability was refused exactly like a stranger.
let repo = repo_owned_by(OWNER_DID);
let verified = push_delegation(OWNER_DID, &repo);
assert!(
owner_push_rejection(true, &repo, Some(STRANGER_DID), Some(&verified)).is_none(),
"a delegation rooted at the owner must let a non-owner push"
);
}
#[test]
fn enforced_rejects_a_delegation_rooted_at_a_stranger() {
// Anchoring is the whole point: a chain nobody the repo trusts started
// grants nothing, even carrying a perfectly formed push capability.
let repo = repo_owned_by(OWNER_DID);
let verified = push_delegation(STRANGER_DID, &repo);
assert_forbidden(owner_push_rejection(
true,
&repo,
Some(STRANGER_DID),
Some(&verified),
));
}
#[test]
fn enforced_still_rejects_a_non_owner_with_no_capability() {
let repo = repo_owned_by(OWNER_DID);
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
}
#[test]
fn caller_authorized_to_push_is_owner_only_in_phase_1() {
let repo = repo_owned_by(OWNER_DID);
assert!(caller_authorized_to_push(&repo, OWNER_DID));
assert!(caller_authorized_to_push(&repo, OWNER_SHORT));
assert!(!caller_authorized_to_push(&repo, STRANGER_DID));
assert!(caller_authorized_to_push(&repo, OWNER_DID, None));
assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None));
assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None));
}
// ── fork_withheld_blocks (#98 path-scoped fork gate) ──
@@ -5940,6 +6016,7 @@ mod tests {
State(state.clone()),
Path(("z6rp4wr".to_string(), "rp4".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(capped)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -5958,6 +6035,7 @@ mod tests {
State(state.clone()),
Path(("z6rp4wr".to_string(), "rp4".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(other)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6064,6 +6142,7 @@ mod tests {
State(state_for_task),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6151,6 +6230,7 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6308,6 +6388,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6MkDisconnectWriteLockProofDidAAAAAAAA".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -6462,6 +6543,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6MkPushSuccessReleaseProofDidAAAAAAAA".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -6552,6 +6634,7 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -6570,6 +6653,7 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.72:5000".parse().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -7218,6 +7302,7 @@ mod tests {
State(state),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
ref_update_body(new_sha),
@@ -7312,6 +7397,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f4fast".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -7372,6 +7458,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f4park".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
ref_update_body("2222222222222222222222222222222222222222"),
@@ -8597,6 +8684,7 @@ mod tests {
State(state.clone()),
Path(("z6f3repo".to_string(), "r1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8625,6 +8713,7 @@ mod tests {
State(state_b),
Path(("z6f3repo".to_string(), "r1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.82:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -8721,6 +8810,7 @@ mod tests {
State(st),
Path(("z6f3clean".to_string(), "c1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8814,6 +8904,7 @@ mod tests {
State(state.clone()),
Path(("z6f3dos".to_string(), "d1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -8841,6 +8932,7 @@ mod tests {
State(state_b),
Path(("z6f3dos".to_string(), "d1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(
"203.0.113.72:5000".parse::<SocketAddr>().unwrap(),
)),
@@ -9025,6 +9117,7 @@ mod tests {
State(st),
Path(("z6u2key".to_string(), "k1".to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9098,6 +9191,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6ovflow".to_string(),
)),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
ref_update_body("1111111111111111111111111111111111111111"),
@@ -9152,6 +9246,7 @@ mod tests {
State(st),
Path(("z6u1cap".to_string(), "c1".to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9257,6 +9352,7 @@ mod tests {
State(st),
Path(("z6u1two".to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(did)),
None,
crate::rate_limit::PeerAddr(Some(src)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9347,6 +9443,7 @@ mod tests {
State(st),
Path((owner.to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some(edge)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9440,6 +9537,7 @@ mod tests {
State(st),
Path(("z6f1key".to_string(), repo.to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(peer)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9500,6 +9598,7 @@ mod tests {
Extension(crate::auth::AuthenticatedDid(
"did:key:z6f1none".to_string(),
)),
None,
crate::rate_limit::PeerAddr(None),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -9544,6 +9643,7 @@ mod tests {
State(state.clone()),
Path(("z6f1seq".to_string(), "s1".to_string())),
Extension(crate::auth::AuthenticatedDid(did.to_string())),
None,
crate::rate_limit::PeerAddr(Some(src)),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
@@ -10054,6 +10154,7 @@ mod tests {
State(state.clone()),
Path((owner.to_string(), name.to_string())),
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
None,
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
axum::http::HeaderMap::new(),
axum::body::Bytes::from_static(b"0000"),
+514 -14
View File
@@ -17,16 +17,74 @@ use crate::state::AppState;
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// A UCAN that passed full chain validation, with the root issuer the chain
/// rests on. Inserted into request extensions by [`require_ucan_chain`] when
/// `X-Ucan` is present; absent when the header is.
///
/// `root` is carried rather than recomputed so the chain is walked once per
/// request. Holding this is not itself an authorization decision — a caller must
/// still compare `root` against an identity it independently trusts, because
/// `did:key` is self-certifying and anyone can mint a chain that verifies.
#[derive(Clone, Debug)]
pub struct VerifiedUcan {
pub ucan: Ucan,
pub root: Did,
}
/// Whether `caller` is authorized to push to `record`.
///
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
/// intentionally a distinct, intent-named gate rather than a bare owner check so
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
/// call sites.
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
/// The repo owner, or a caller presenting a verified UCAN whose chain roots at
/// that owner and which carries `git/push` for this repo.
///
/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches
/// the same owner-only decision it always did. The owner check is unconditional
/// and runs first, so this can only ever turn a refusal into an acceptance,
/// never the reverse.
pub fn caller_authorized_to_push(
record: &crate::db::RepoRecord,
caller: &str,
verified: Option<&VerifiedUcan>,
) -> bool {
crate::api::did_matches(caller, &record.owner_did)
|| verified.is_some_and(|v| ucan_grants_push(record, v))
}
/// Whether a verified UCAN authorizes a push to `record`.
///
/// Three conditions, all required:
/// 1. The chain roots at this repo's owner. This is the trust anchor — the
/// repo record is data the node holds independently of the token, so a
/// self-minted chain cannot satisfy it.
/// 2. Every link is bounded. There is no revocation path, so an unbounded link
/// is a permanent grant.
/// 3. Every link — the leaf and every proof behind it — carries an
/// unconstrained push-class capability naming this repository. That is
/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule
/// `gl ucan import` applies before storing a token and `git-remote-gitlawb`
/// applies before minting an invocation, so what one boundary accepts the
/// next does not refuse.
///
/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did
/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and
/// that narrowing was exactly what the helper performed, so one owner-issued
/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the
/// owner had — or created later — and both the chain check and the owner-root
/// check passed. A delegation's scope is fixed when it is issued.
pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool {
if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) {
return false;
}
// A write capability must lapse on its own. `exp` is optional in the format and
// there is no revocation path, so a chain with an unbounded link is a permanent
// grant: once the token leaks, the owner cannot withdraw it short of rotating
// the DID the repo is keyed on. Refusing here is what makes "the damage window
// is the token's expiry" a true statement rather than an aspiration.
if !verified.ucan.chain_lifetime_is_bounded() {
return false;
}
verified
.ucan
.chain_grants_push_to(&record.owner_did, &record.name)
}
use gitlawb_core::http_sig::{
@@ -270,7 +328,7 @@ fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
@@ -298,14 +356,14 @@ fn validate_ucan_chain(
)
})?;
ucan.verify_chain().map_err(|e| {
let root = ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(())
Ok(VerifiedUcan { ucan, root })
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
@@ -358,11 +416,18 @@ pub async fn require_ucan_chain(
}
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
Ok(v) => v,
Err((status, body)) => return (status, body).into_response(),
};
tracing::debug!(did = %signer_did, "UCAN chain validated");
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
// Park the verified token where a handler can reach it. Validation alone
// grants nothing; the authorization decision is made downstream, by a caller
// that knows which identity it trusts for the resource being touched.
let mut request = request;
request.extensions_mut().insert(verified);
next.run(request).await
}
@@ -398,6 +463,37 @@ mod tests {
Ucan::bootstrap(node, agent_did).unwrap()
}
/// The middleware validated a token and threw the result away, so no handler
/// could ever read it and `Ucan::can` had no call site in the node. Validation
/// must hand back both the token and the root the chain rests on.
#[test]
fn validate_ucan_chain_hands_back_the_root_and_the_token() {
let owner = Keypair::generate();
let agent = Keypair::generate();
let node = Keypair::generate();
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
let delegation =
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
.expect("wrap invocation");
let token = invocation.encode().expect("encode");
let verified = validate_ucan_chain(&token, &node.did(), &agent.did())
.expect("a well-formed owner-rooted invocation must validate");
assert_eq!(
verified.root,
owner.did(),
"the root must be the owner, so a caller can anchor against the repo record"
);
assert_eq!(
verified.ucan.payload.iss,
agent.did(),
"the token itself must come back so a caller can read its capabilities"
);
}
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
Ucan::delegate(
agent,
@@ -637,3 +733,407 @@ mod tests {
assert_eq!(body_json["error"], "invalid_ucan");
}
}
#[cfg(test)]
mod ucan_push_tests {
use super::*;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
/// `RepoRecord` does not derive `Default`, and adding the derive to a
/// production DB type purely to serve a test is the wrong direction.
fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
crate::db::RepoRecord {
id: "repo-id".to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
disk_path: "/unused".to_string(),
forked_from: None,
machine_id: None,
}
}
/// The token's own issuer and audience are irrelevant to this predicate: the
/// middleware has already bound `iss` to the request signer and `aud` to this
/// node. Only the capabilities and the chain's root matter here.
fn verified(root: &str, caps_vec: Vec<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");
}
}
}
+450
View File
@@ -2390,6 +2390,456 @@ mod tests {
authorization"
);
}
/// Delegated push, end to end through both auth layers.
///
/// A non-owner presenting an invocation whose chain roots at the repo owner
/// clears the owner-push gate; the same signer without one, and with one that
/// names a different repository, are both refused. This is the regression the
/// owner-push default introduced: a CI or delegated key holding a valid
/// `git/push` capability was refused exactly like a stranger.
///
/// Status codes are the discriminators. 500 means the request passed
/// `require_signature` (not 401), passed `require_ucan_chain` (not 401), and
/// cleared the owner gate (not 403), then reached git on a repo with no disk
/// backing — the same shape `git_upload_pack_post_is_read_gated_on_private_repo`
/// relies on. A bare `!= 403` would let a 401 regression pass.
///
/// Not `#[cfg(unix)]`: no fake-git shim is involved, only HTTP and the gate.
#[sqlx::test]
async fn delegated_push_clears_the_owner_gate(pool: PgPool) {
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::sync::Arc;
let owner = Keypair::generate();
let agent = Keypair::generate();
let owner_did = owner.did().to_string();
let short = owner_did.split(':').next_back().unwrap().to_string();
let mut state = test_state(pool).await;
// Explicit rather than relying on the shipped default, so this test states
// the configuration it is about.
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = true;
state.config = Arc::new(cfg);
state
.db
.create_repo(&seed_repo(&owner_did, "deleg"))
.await
.expect("seed repo");
let router = || {
Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
axum::routing::post(crate::api::repos::git_receive_pack),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
crate::auth::require_ucan_chain,
))
.layer(axum::middleware::from_fn(crate::auth::require_signature))
.with_state(state.clone())
};
let path = format!("/{short}/deleg.git/git-receive-pack");
let body = b"0000".to_vec();
// owner -> agent delegation, then agent -> node invocation carrying it.
// Both links carry a finite expiry: a write capability that never lapses is
// refused, since there is no revocation path to withdraw a leaked one.
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let invocation_with_exp = |resource: String, exp: Option<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
+8 -7
View File
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -73,17 +73,18 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!("gl doctor — checking your gitlawb setup");
println!();
let dir = args.dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// The same resolver every other command uses. `doctor` reporting on
// `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would
// make the one command whose job is to explain a broken setup the one that
// misreports it.
let args_dir = args.dir.clone();
let dir = crate::identity::gitlawb_dir(args.dir)?;
let mut checks = Vec::new();
let mut all_ok = true;
// ── 1. Identity ───────────────────────────────────────────────────────
let pem_path = dir.join("identity.pem");
let pem_path = crate::identity::key_path_for(args_dir.as_deref())?;
if pem_path.exists() {
match std::fs::read_to_string(&pem_path)
.ok()
+2 -2
View File
@@ -323,8 +323,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
/// `char::is_control` does not cover — they can reorder the displayed line).
pub(crate) fn sanitize_node_msg(s: &str) -> String {
s.chars()
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
gitlawb_core::sanitize::strip_terminal_controls(s)
.chars()
.take(200)
.collect()
}
+239 -20
View File
@@ -9,7 +9,8 @@ use std::path::{Path, PathBuf};
pub enum IdentityCmd {
/// Generate a new Ed25519 keypair and DID
New {
/// Output directory for key files (default: ~/.gitlawb)
/// Output directory for key files
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Overwrite existing keys if present
@@ -63,16 +64,51 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
}
}
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
/// 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> {
if let Some(d) = override_dir {
return Ok(d);
}
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".gitlawb"))
// `home_dir()` is passed as an Option rather than demanded here: an absolute
// GITLAWB_KEY resolves without a home directory, and a host that has none is a
// normal container shape, not a reason to refuse a correctly-configured key.
let home = dirs::home_dir();
gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))
}
fn key_path(dir: &Path) -> PathBuf {
dir.join("identity.pem")
/// The identity PEM to read or write.
///
/// With an explicit `--dir` this is `<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 load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
@@ -81,15 +117,14 @@ 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 base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
let path = key_path(&base);
let path = key_path_for(dir)?;
let pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
@@ -108,8 +143,11 @@ async fn cmd_new_with_reader(
force: bool,
reader: &mut impl std::io::BufRead,
) -> Result<()> {
let dir = gitlawb_dir(dir)?;
let path = key_path(&dir);
let path = key_path_for(dir.as_deref())?;
let dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if path.exists() {
if force {
@@ -183,8 +221,7 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
}
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let src = key_path_for(dir.as_deref())?;
let pem = fs::read_to_string(&src).with_context(|| {
format!(
@@ -239,8 +276,11 @@ async fn cmd_restore_with_reader(
// Verify it's a valid keypair before writing anything
let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?;
let base = gitlawb_dir(dir)?;
let dest = key_path(&base);
let dest = key_path_for(dir.as_deref())?;
let base = dest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if dest.exists() {
if force {
@@ -509,3 +549,182 @@ mod tests {
assert_eq!(original_did, dst_kp.did());
}
}
/// Scoped `GITLAWB_KEY` for tests, shared crate-wide.
///
/// The process environment is global and more than one suite in this crate
/// depends on it — the resolver's own cases here, and `gl register`'s check that
/// the bootstrap token lands beside the key. They take one lock rather than each
/// declaring its own, which would not serialise them against each other.
#[cfg(test)]
pub(crate) mod test_env {
use std::ffi::{OsStr, OsString};
use std::sync::{Mutex, MutexGuard};
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Restores the previous value and releases the lock on drop.
pub(crate) struct KeyEnv {
_guard: MutexGuard<'static, ()>,
restore: Option<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")
);
}
}
+7 -13
View File
@@ -22,7 +22,7 @@ pub struct InitArgs {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -101,10 +101,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
// Save UCAN if returned
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
if !ucan.is_empty() {
let ucan_dir = args
.dir
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?;
std::fs::create_dir_all(&ucan_dir)?;
let record = json!({
"ucan": ucan,
@@ -221,18 +218,15 @@ pub async fn run(args: InitArgs) -> Result<()> {
}
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
let base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
let path = crate::identity::key_path_for(dir)?;
let base = path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&base)?;
let keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
let path = base.join("identity.pem");
#[cfg(unix)]
{
+1 -1
View File
@@ -24,7 +24,7 @@ pub enum IpfsCmd {
List {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+415 -33
View File
@@ -283,7 +283,7 @@ fn tool_definitions() -> Value {
},
{
"name": "ucan_show",
"description": "Show the saved bootstrap UCAN token for this agent.",
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
"inputSchema": { "type": "object", "properties": {} }
},
{
@@ -560,7 +560,7 @@ fn tool_definitions() -> Value {
// ── UCAN delegation tools ───────────────────────────────────────────
{
"name": "ucan_delegate",
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
"inputSchema": {
"type": "object",
"required": ["to", "resource", "action"],
@@ -568,13 +568,13 @@ fn tool_definitions() -> Value {
"to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" },
"resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" },
"action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" },
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
"expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." }
}
}
},
{
"name": "ucan_verify",
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
"inputSchema": {
"type": "object",
"required": ["token"],
@@ -583,6 +583,17 @@ fn tool_definitions() -> Value {
}
}
},
{
"name": "ucan_import",
"description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.",
"inputSchema": {
"type": "object",
"required": ["token"],
"properties": {
"token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" }
}
}
},
// ── Issue tools ─────────────────────────────────────────────────────
{
@@ -759,15 +770,45 @@ async fn call_tool(
]))?),
"ucan_show" => {
let ucan_path = dirs::home_dir()
.context("no home dir")?
.join(".gitlawb/ucan.json");
if ucan_path.exists() {
let content = std::fs::read_to_string(ucan_path)?;
Ok(content)
} else {
Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string())
// The directory the server was started with, like every sibling tool.
// Reading the default here while the rest honour `--dir` splits one MCP
// session across two identity directories.
let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?
.join("ucan.json");
if !ucan_path.exists() {
return Ok(
"No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(),
);
}
let content = std::fs::read_to_string(&ucan_path)?;
// Decoded fields, matching what `gl ucan show` reports — the two are the
// same question asked through different surfaces and should not answer
// it in different shapes.
//
// Returning the file verbatim also handed the caller the bootstrap token
// itself. That is the credential the agent presents, and an MCP response
// travels further than a terminal: into a model's context, transcripts,
// and logs. The fields below are what a caller actually needs to know
// whether it is registered and until when.
let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| {
format!("could not read the saved UCAN at {}", ucan_path.display())
})?;
Ok(serde_json::to_string_pretty(&json!({
"issuer": ucan.payload.iss.to_string(),
"audience": ucan.payload.aud.to_string(),
"version": ucan.payload.ucan,
"capabilities": ucan.payload.att.iter()
.map(|c| json!({ "with": c.with, "can": c.can }))
.collect::<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" => {
@@ -1125,14 +1166,35 @@ async fn call_tool(
let resource = args["resource"].as_str().context("missing 'resource'")?;
let action = args["action"].as_str().context("missing 'action'")?;
// The same refusal `gl ucan delegate` applies. A push-class wildcard cannot
// be honoured anywhere downstream — import refuses it, the helper refuses
// it, the node refuses it — so issuing one only manufactures a token that
// fails later with less context than this.
if gitlawb_core::ucan::push::is_push_wildcard(resource, action) {
anyhow::bail!(
"a wildcard resource cannot carry a push capability: a delegation is \n scoped to the repositories it names when issued, and `*` cannot say \n which those were. Use resource gitlawb://repos/<owner>/<repo>."
);
}
let audience: gitlawb_core::did::Did = to_str
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
let exp = args
.get("expiry_hours")
.and_then(|v| v.as_i64())
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
// The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way
// to issue an unbounded token. An omitted field here used to mean "no
// expiry", which for a push capability is dead on arrival: import
// refuses it and so does the node (`chain_lifetime_is_bounded`). Same
// default, same rule.
let hours = match args.get("expiry_hours") {
None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64,
Some(v) => v
.as_i64()
.context("invalid expiry_hours: expected an integer")?,
};
if hours < 1 {
anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing");
}
let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours));
let ucan = gitlawb_core::ucan::Ucan::issue(
&kp,
@@ -1155,23 +1217,26 @@ async fn call_tool(
let token = args["token"].as_str().context("missing 'token'")?;
let ucan =
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
let sig_valid = ucan.verify_signature().is_ok();
let expired = ucan.is_expired();
let caps: Vec<Value> = ucan
.payload
.att
.iter()
.map(|c| json!({ "with": c.with, "can": c.can }))
.collect();
// The same report `gl ucan verify` prints, so the two cannot disagree
// about what "valid" means. In particular the proof chain is walked:
// a leaf with a good signature on a broken chain used to report valid
// here and then fail at import and at the node.
let report = crate::ucan_cmd::VerifyReport::of(&ucan);
Ok(serde_json::to_string_pretty(&report.to_json())?)
}
"ucan_import" => {
let token = args["token"].as_str().context("missing 'token'")?;
// `gl ucan import` with a JSON answer: one set of checks, one store, so
// an MCP-only agent can complete the delegated-push workflow without
// a shell and without a second copy of the rules.
let imported = crate::ucan_cmd::import_delegation(token, dir).await?;
Ok(serde_json::to_string_pretty(&json!({
"valid": sig_valid && !expired,
"signature_valid": sig_valid,
"expired": expired,
"issuer": ucan.payload.iss.to_string(),
"audience": ucan.payload.aud.to_string(),
"capabilities": caps,
"expires": ucan.payload.exp,
"owner": imported.owner,
"repo": imported.repo,
"root_issuer": imported.root,
"expires": imported.expires,
"path": imported.path.display().to_string(),
}))?)
}
@@ -1564,6 +1629,7 @@ mod tests {
.collect();
assert!(names.contains(&"ucan_delegate"));
assert!(names.contains(&"ucan_verify"));
assert!(names.contains(&"ucan_import"));
}
#[test]
@@ -1730,6 +1796,38 @@ mod tests {
assert_eq!(parsed["capability"]["can"], "git/push");
}
/// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named
/// resource, so it could never catch the MCP arm issuing a wildcard the CLI
/// refuses.
#[tokio::test]
async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
for action in ["git/push", "*", "repo/admin"] {
let err = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "*",
"action": action,
"expiry_hours": 24,
}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("MCP must refuse a push-class wildcard like the CLI does");
assert!(err.to_string().contains("wildcard"), "{action}: {err}");
}
}
#[tokio::test]
async fn test_ucan_verify_via_mcp() {
let kp = gitlawb_core::identity::Keypair::generate();
@@ -1758,6 +1856,204 @@ mod tests {
assert_eq!(parsed["valid"], true);
assert_eq!(parsed["signature_valid"], true);
assert_eq!(parsed["expired"], false);
assert_eq!(parsed["chain_valid"], true);
assert_eq!(parsed["root_issuer"], kp.did().to_string());
}
/// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a
/// good signature sitting on a proof that never named its issuer reported
/// `valid: true` here, and was then refused by import and by the node.
#[tokio::test]
async fn test_ucan_verify_via_mcp_walks_the_proof_chain() {
let alice = gitlawb_core::identity::Keypair::generate();
let bob = gitlawb_core::identity::Keypair::generate();
let eve = gitlawb_core::identity::Keypair::generate();
let node = gitlawb_core::identity::Keypair::generate();
let cap = || {
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://repos/alice/r",
"git/push",
)]
};
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
// Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's
// own signature is fine — the break is in the chain.
let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap();
let forged =
gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap();
assert!(
forged.verify_signature().is_ok(),
"fixture: the leaf signature is valid"
);
let result = call_tool(
"ucan_verify",
json!({"token": forged.encode().unwrap()}),
"http://localhost",
None,
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["signature_valid"], true);
assert_eq!(parsed["expired"], false);
assert_eq!(parsed["chain_valid"], false);
assert_eq!(
parsed["valid"], false,
"a broken proof chain must not report valid: {parsed}"
);
assert!(
parsed["chain_error"]
.as_str()
.unwrap_or_default()
.contains("proof chain broken"),
"the reason must be reported: {parsed}"
);
assert!(parsed["root_issuer"].is_null());
}
/// The workflow an MCP-only agent could not finish: it could be issued a
/// delegation and verify it, but not store it where `git-remote-gitlawb`
/// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same
/// file — so a token the CLI would store lands in the same place, and one the
/// CLI would refuse is refused with the same reason.
#[tokio::test]
async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() {
let dir = tempfile::TempDir::new().unwrap();
let me = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
me.to_pem().unwrap().as_bytes(),
)
.unwrap();
let owner = gitlawb_core::identity::Keypair::generate();
let owner_did = owner.did().to_string();
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
let token = gitlawb_core::ucan::Ucan::issue(
&owner,
me.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{owner_did}/ci-repo"),
"git/push",
)],
Some(hour),
)
.unwrap()
.encode()
.unwrap();
let result = call_tool(
"ucan_import",
json!({"token": token}),
"http://localhost",
Some(dir.path()),
)
.await
.expect("a delegation addressed to this identity must import");
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["owner"], owner_did);
assert_eq!(parsed["repo"], "ci-repo");
assert_eq!(parsed["root_issuer"], owner_did);
assert_eq!(parsed["expires"], hour.timestamp());
let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo");
assert_eq!(parsed["path"], stored.display().to_string());
assert_eq!(
std::fs::read_to_string(&stored).unwrap(),
token,
"the token must be stored byte-for-byte where the helper reads it"
);
// Same refusals as the CLI: a token addressed to someone else is unusable
// here however well-formed it is.
let stranger = gitlawb_core::identity::Keypair::generate();
let misaddressed = gitlawb_core::ucan::Ucan::issue(
&owner,
stranger.did(),
vec![gitlawb_core::ucan::Capability::new(
format!("gitlawb://repos/{owner_did}/ci-repo"),
"git/push",
)],
Some(hour),
)
.unwrap()
.encode()
.unwrap();
let err = call_tool(
"ucan_import",
json!({"token": misaddressed}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("a token addressed to another identity must be refused");
assert!(err.to_string().contains("addressed to"), "{err}");
}
/// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which
/// import and the node both refuse for push. The default is now the CLI's.
#[tokio::test]
async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
let before = chrono::Utc::now();
let result = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "gitlawb://repos/test/repo",
"action": "git/push",
}),
"http://localhost",
Some(dir.path()),
)
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let expires = parsed["expires"]
.as_i64()
.expect("an omitted expiry_hours must still produce an expiring token");
let expected = (before
+ chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64))
.timestamp();
assert!(
(expires - expected).abs() <= 60,
"expiry must default to the CLI's {} hours, got {expires} vs {expected}",
crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS
);
let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap();
assert!(
token.chain_lifetime_is_bounded(),
"the token must satisfy the rule import and the node apply"
);
// A token that has already expired grants nothing, so the request is a
// mistake to report, not a token to mint.
for bad in [json!(0), json!(-5)] {
let err = call_tool(
"ucan_delegate",
json!({
"to": audience.did().to_string(),
"resource": "gitlawb://repos/test/repo",
"action": "git/push",
"expiry_hours": bad,
}),
"http://localhost",
Some(dir.path()),
)
.await
.expect_err("a non-positive expiry must be refused");
assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}");
}
}
#[tokio::test]
@@ -2027,9 +2323,95 @@ mod tests {
}
#[test]
fn test_tool_count_is_42() {
fn test_tool_count_matches_the_shipped_list() {
let tools = tool_definitions();
let count = tools.as_array().unwrap().len();
assert_eq!(count, 40, "expected 40 tools, got {count}");
// 41 with `ucan_import`. Bump this when a tool is added or removed; the
// point is that no tool is added without someone noticing.
assert_eq!(count, 41, "expected 41 tools, got {count}");
}
}
#[cfg(test)]
mod ucan_show_tests {
use super::*;
/// The MCP tool and `gl ucan show` answer the same question and must answer it
/// in the same shape — decoded fields, not the file. Returning the file verbatim
/// also handed back the bootstrap token, which an MCP response carries much
/// further than a terminal does.
#[tokio::test]
async fn ucan_show_returns_decoded_fields_and_withholds_the_token() {
let dir = tempfile::TempDir::new().unwrap();
let issuer = gitlawb_core::identity::Keypair::generate();
let audience = gitlawb_core::identity::Keypair::generate();
let token = gitlawb_core::ucan::Ucan::issue(
&issuer,
audience.did(),
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://repos/z6MkAbc/myrepo",
gitlawb_core::ucan::caps::GIT_PUSH,
)],
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
)
.unwrap()
.encode()
.unwrap();
// The envelope shape every writer produces.
std::fs::write(
dir.path().join("ucan.json"),
serde_json::to_string_pretty(&json!({
"ucan": token,
"node": "https://node.gitlawb.com",
"did": issuer.did().to_string(),
"saved_at": "2026-08-18T00:00:00Z",
}))
.unwrap(),
)
.unwrap();
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
.await
.expect("ucan_show must read the envelope every writer produces");
let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON");
assert_eq!(v["issuer"], issuer.did().to_string());
assert_eq!(v["audience"], audience.did().to_string());
assert_eq!(v["capabilities"][0]["can"], "git/push");
assert_eq!(v["expired"], false);
assert_eq!(v["signature_valid"], true);
// NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in
// a pretty-printed response escapes every quote and that assertion passes
// whether or not the token leaked. The signature is a bare base64 string that
// survives escaping unchanged, so it is the substring that actually proves
// absence.
let sig = serde_json::from_str::<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}");
}
}
+2 -10
View File
@@ -168,16 +168,8 @@ 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 = identity_dir(dir).join("identity.pem");
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
@@ -190,7 +182,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = identity_dir(dir).join("identity.pem");
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+1 -1
View File
@@ -22,7 +22,7 @@ pub enum NodeCmd {
Status {
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
},
+4 -6
View File
@@ -328,12 +328,10 @@ pub async fn cmd_unstake(
// ── Helpers ─────────────────────────────────────────────────────────────────
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let base = dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
let path = base.join("identity.pem");
// The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the
// key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing
// home made the answer depend on the working directory.
let path = crate::identity::key_path_for(dir.as_deref())?;
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
+46 -20
View File
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
pub node: String,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
@@ -39,19 +39,26 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!("and create your first repository.");
println!();
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
});
// The wizard generates the identity AND stores the bootstrap UCAN, so it has to
// land where every later command reads from — the parent of `GITLAWB_KEY`, not
// an unconditional `~/.gitlawb`.
let dir = crate::identity::gitlawb_dir(args.dir.clone())?;
// ── Step 1: Identity ──────────────────────────────────────────────────
println!("── Step 1: Identity ─────────────────────────────────────────────────");
println!();
let pem_path = dir.join("identity.pem");
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
let keypair = if pem_path.exists() {
match load_keypair_from_dir(Some(&dir)) {
// `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the
// loader re-derive the basename as `identity.pem`, so with
// `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem`
// while the load looked for a sibling that need not exist — and the Err arm
// below then regenerated ONTO `ci-agent.pem`, destroying a working key and
// changing the DID that repository ownership, registrations, and delegations
// are all tied to. Regeneration must follow a failure to read the file that
// was actually selected, never the absence of a conventional sibling.
match load_keypair_from_dir(args.dir.as_deref()) {
Ok(kp) => {
let did = kp.did();
println!(" ✓ Identity already exists");
@@ -59,14 +66,15 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
kp
}
Err(_) => {
println!(" Identity file exists but is unreadable. Regenerating...");
generate_identity(&dir)?
Err(e) => {
println!(" Identity at {} is unreadable: {e}", pem_path.display());
println!(" Regenerating — the previous key cannot be recovered.");
generate_identity(&dir, &pem_path)?
}
}
} else {
println!(" No identity found. Generating a new Ed25519 keypair...");
generate_identity(&dir)?
generate_identity(&dir, &pem_path)?
};
let did = keypair.did().to_string();
@@ -99,7 +107,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
Ok(resp) if resp.status().is_success() => {
let payload: Value = resp.json().await.unwrap_or_default();
let ucan = payload["ucan"].as_str().unwrap_or("");
if !ucan.is_empty() {
// The messaging below derives from what was actually persisted, not
// from the 2xx: a success response carrying no `ucan` skips the write,
// and saying "UCAN saved" anyway sends the operator looking for a file
// that was never created — then resurfaces later as a push rejection
// with nothing pointing back here.
let saved = if ucan.is_empty() {
false
} else {
std::fs::create_dir_all(&dir)?;
let record = json!({
"ucan": ucan,
@@ -108,11 +123,20 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
}
true
};
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
println!(" ✓ Registered successfully");
println!(" Trust score: {trust:.2}");
println!(" UCAN saved to {}", ucan_path.display());
if saved {
println!(" UCAN saved to {}", ucan_path.display());
} else {
println!(" The node returned no bootstrap UCAN, so registration-gated");
println!(
" workflows are unavailable. Retry with: gl register --node {}",
args.node
);
}
println!();
}
Ok(resp) => {
@@ -226,22 +250,24 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
fn generate_identity(
dir: &PathBuf,
path: &std::path::Path,
) -> 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();
+77 -17
View File
@@ -1,7 +1,8 @@
//! `gl register` — register this agent identity with a gitlawb node.
//!
//! Sends a signed POST /api/register request and saves the returned bootstrap
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
//! UCAN token as `ucan.json` beside the identity key, where the other commands
//! look for it.
use anyhow::{Context, Result};
use clap::Args;
@@ -29,7 +30,7 @@ pub struct RegisterArgs {
#[arg(long)]
pub model: Option<String>,
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
pub dir: Option<PathBuf>,
}
@@ -69,17 +70,20 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
if !ucan.is_empty() {
let ucan_path = ucan_path(args.dir.as_deref())?;
let saved_to = if ucan.is_empty() {
None
} else {
let path = ucan_path(args.dir.as_deref())?;
let record = json!({
"ucan": ucan,
"node": args.node,
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
std::fs::write(&path, serde_json::to_string_pretty(&record)?)?;
tracing::debug!("saved UCAN to {}", path.display());
Some(path)
};
let trust = payload
.get("trust_score")
@@ -99,21 +103,35 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
println!(" Trust score: {trust:.2}");
println!(" UCAN expires: {expires}");
println!();
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
println!(" You are now a verified agent on the gitlawb network.");
// The real path, not the default one: `GITLAWB_KEY` moves it, and an operator
// told to look in `~/.gitlawb` would find nothing there.
// Registration without a stored token means the registration-gated
// capabilities never arrived, so the closing line must not claim they did.
match &saved_to {
Some(path) => {
println!(" Bootstrap UCAN saved to {}", path.display());
println!(" You are now a verified agent on the gitlawb network.");
}
None => {
println!(" The node returned no bootstrap UCAN, so this identity is not");
println!(" a verified agent yet. Re-run `gl register` once the node issues one.");
}
}
Ok(())
}
/// Where the bootstrap UCAN is stored: beside the identity key, always.
///
/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The
/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere
/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`,
/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's
/// directory and report an unregistered identity.
fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
let base = if let Some(d) = dir {
d.to_path_buf()
} else {
dirs::home_dir()
.context("could not determine home directory")?
.join(".gitlawb")
};
std::fs::create_dir_all(&base)?;
let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?;
std::fs::create_dir_all(&base)
.with_context(|| format!("failed to create {}", base.display()))?;
Ok(base.join("ucan.json"))
}
@@ -160,6 +178,48 @@ mod tests {
assert_eq!(content["node"].as_str().unwrap(), server.url());
}
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
/// silent split-brain: registration prints success, and every command that later
/// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks
/// beside the key, finds nothing, and reports an unregistered identity.
#[tokio::test]
async fn register_saves_the_bootstrap_ucan_beside_the_key() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
// No --dir: the destination has to come from GITLAWB_KEY alone.
let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem")));
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("POST", "/api/register")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#,
)
.create_async()
.await;
run(RegisterArgs {
node: server.url(),
capabilities: vec!["git:push".to_string()],
model: None,
dir: None,
})
.await
.unwrap();
let beside_the_key = dir.path().join("ucan.json");
assert!(
beside_the_key.exists(),
"the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb"
);
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap();
assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token");
}
#[tokio::test]
async fn test_register_server_error() {
let dir = TempDir::new().unwrap();
+1644 -39
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct WhoamiArgs {
/// Identity directory (default: ~/.gitlawb)
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
#[arg(long)]
dir: Option<PathBuf>,
/// Node URL to query for registration info
+79 -11
View File
@@ -143,9 +143,11 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
## Owner-only push
The node requires the authenticated pusher to be the repo owner on **every**
branch. A push whose authenticated DID is not the repo owner is rejected with
HTTP 403 before any ref update is applied. The owner is matched in both the full
On the `git-receive-pack` POST — the push itself — the node requires the
authenticated pusher to be the repo owner, or to present a UCAN whose proof chain
roots at the owner and carries `git/push` for this repository (see *Delegating
push to a CI agent* below). Anyone else is rejected with HTTP 403 before any ref
update is applied, on **every** branch. The owner is matched in both the full
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
This is on by default, and the default is the point. The node authenticates every
@@ -155,6 +157,20 @@ Authentication is not authorization, so without this gate every signed caller ca
push to every repository — private ones included — on any branch that is not
explicitly protected.
A `git push` crosses two independent gates, and this section is about the second:
1. **Read visibility**, on the `info/refs` advertisement that precedes every fetch
*and* every push. The node admits the owner, anyone on a public repository, and
a caller that a visibility rule names among its readers; everyone else gets a
404, since a private repository is not confirmed to exist for a non-reader. A
UCAN plays no part here — a delegation grants no read access.
2. **Push authorization**, on the `git-receive-pack` POST: the owner, or an
owner-rooted, bounded, repository-scoped push delegation.
Both must pass. On a public repository the first is open to everyone, so a
delegation alone is enough; on a private one the delegate must also be a reader —
see *Private repositories* below.
### Turning it off
```bash
@@ -168,16 +184,68 @@ Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
treat it as temporary: while it is off, your node accepts a push to any repository
from anyone who can generate a keypair.
### Caution: delegated and CI keys are non-owners
- **When `true`** — a push whose authenticated DID is not the repo owner is
rejected (HTTP 403) before any ref update is applied. The owner is matched in
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
- **A delegated key can still push.** A non-owner clears this gate by presenting a
UCAN whose proof chain roots at the repo owner and which carries `git/push` for
this repository. See *Delegating push to a CI agent* below.
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.
### Delegating push to a CI agent
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.
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.
---