Compare commits

..
Author SHA1 Message Date
beardthelion cddb525420 fix(git-remote): gate delegation_header on import's push predicates and the file cap
- a stored delegation that is expired or has an unbounded link is now
  ignored instead of attached: the node would refuse it at middleware
  with a distinguishable invalid_ucan response, and omitting the header
  keeps the denial on the unified owner-push path
- the store file is size-checked against the same 1 MiB cap
  MAX_TOKEN_FILE_BYTES applies at import; an oversize entry is ignored
  like an unreadable one rather than read into memory on every push
- pins: an expired on-disk delegation produces no X-Ucan (and no node
  probe); an oversize store entry produces none either
2026-11-10 16:43:45 -06:00
beardthelion ea8c7f3ad9 Merge remote-tracking branch 'origin/main' into pr-331 2026-11-10 16:42:41 -06:00
beardthelion f1a2cb4c79 fix(gl): judge push readiness with the shared push predicates in ucan verify
- push_class now uses the same is_push_action rule import and the node
  apply, so repo/admin and * actions classify as push-shaped instead of
  omitting the Push-ready line
- push_ready is a stored verdict, not just bounded+valid: every concrete
  gitlawb://repos/... push capability on the leaf must pass
  chain_grants_push_to, so a wildcard proof behind a concrete leaf
  verifies cryptographically yet reports not push-ready, matching what
  import and git-receive-pack would do
- pins: repo/admin and * actions are push-ready; a wildcard-proof chain
  verifies but is not push-ready
2026-11-10 16:04:52 -06:00
beardthelion d4b90b1433 fix(node,gl): fail closed on protection-lookup errors, surface push readiness in verify
- git-receive-pack: a non-owner whose is_branch_protected lookup errors
  is now refused instead of treated as unprotected; the owner path is
  unchanged (skip the check outright)
- VerifyReport gains bounded_lifetime and push_ready so `gl ucan verify`
  and MCP verify show what import and the node would do with an
  unbounded push chain instead of a bare valid:true
- RUN-A-NODE.md: the owner-push bullet names the delegation exception,
  and the delegation walkthrough now states the GITLAWB_KEY absolute-path
  prerequisite (import --dir must land in the same store the helper reads)
- pins: delegate push refused when the protection lookup errors; an
  unbounded push-shaped delegation verifies but is not push_ready
2026-11-10 15:31:01 -06:00
beardthelion 7ad0b94bea docs(node): gate 1 allow-list semantics, fix comment-strip order in fn_body
- RUN-A-NODE: a `/` rule is an allow-list that replaces the public flag,
  so "public repo = open to everyone / reader step unnecessary" only
  holds when no `/` rule exists; gate-1 bullet, the both-gates summary,
  and the private-repositories section now tell that one story
- authz_guard fn_body strips // line comments before /* */ blocks so a
  line comment containing /* cannot open a range that swallows a gate
2026-11-10 14:17:33 -06:00
beardthelion de7ad7b98c Merge remote-tracking branch 'origin/main' into pr-331 2026-11-10 14:10:07 -06:00
beardthelion 464218c2c9 fix(ucan): refuse key clobber on unreadable identity, fix doc gate sites and message escapes
- quickstart: bail on an existing-but-unreadable identity instead of
  regenerating over it, and write the fresh key with create_new so a
  file that appears mid-setup is not clobbered (same rule gl init uses)
- RUN-A-NODE: gate 1 read visibility applies on both GET info/refs and
  the git-receive-pack POST (same 404 for a client that skips the
  advertisement); visibility set example carries an explicit --mode
- ucan delegate: replace literal \n escapes in the wildcard refusal
  messages (CLI and MCP) with line continuations so the error prints as
  one sentence; MCP expiry bound uses the shared MAX_EXPIRY_HOURS
- MCP ucan_show: run the token version field through shown()
- helper tests: restore GITLAWB_KEY via a drop guard so a panicking case
  cannot leak a tempdir path, and run the advertisement signature test
  under the shared lock with a deterministic empty store
2026-11-10 13:04:23 -06:00
beardthelion 83aa56bed7 Merge remote-tracking branch 'origin/main' into pr-331 2026-11-10 12:53:16 -06:00
beardthelion 8686dce752 fix(ucan): match push capabilities against the canonical repo name
A capability written gitlawb://repos/<owner>/<repo>.git passed import
and filed under the suffixed name, but the helper looks the store up by
the canonical name and the node compared the resource verbatim against
record.name, so the delegation missed at every hop and the push died at
the owner gate with a bare 403.

Introduce ucan::push::canonical_repo_name - the same at-most-one-.git
strip smart_http_repo_name applies - and run it everywhere a push
capability is issued (gl ucan delegate, MCP ucan_delegate), imported
(repo_from_resource now returns the canonical segment, so the file
lands where the helper looks), loaded (split_pack_post_url), and
matched (grants_push_to, hence chain_grants_push_to and the node gate).
At-most-once preserves the literal foo.git repo, reachable as
foo.git.git. Document the canonical form in RUN-A-NODE.md.
2026-11-10 11:30:24 -06:00
beardthelion 14c0b50c80 fix(gl): doctor reports refused resolvers, sanitize MCP UCAN JSON, atomic key write (#567) 2026-11-10 09:20:18 -06:00
beardthelion e75fad9057 fix(gl): drop needless borrow caught by clippy 2026-11-09 23:06:34 -06:00
beardthelion bab62f67d2 test(gl): pin GITLAWB_KEY honouring in load_optional_keypair (#331) 2026-11-09 23:03:42 -06:00
beardthelion 9765cf0193 fix(node,gl): reader gate on receive-pack POST, GITLAWB_KEY in optional load, sanitize + bound UCAN surfaces (#331) 2026-11-09 23:01:56 -06:00
beardthelion e97a250e44 Merge remote-tracking branch 'origin/main' into pr-331 2026-11-09 21:41:10 -06:00
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
14 changed files with 29 additions and 757 deletions

No files matched your search

Generated
-1
View File
@@ -3545,7 +3545,6 @@ dependencies = [
"axum",
"base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
"cid",
"clap",
-106
View File
@@ -1,106 +0,0 @@
# Gitlawb Wire Protocol Specification (v1, alpha)
This document describes the wire behavior a second implementation can interop
with **today**. Anything the node does not ship yet is explicitly marked
*planned*; nothing below is aspirational unless it says so.
## 1. Identity & DIDs
- **Key type:** Ed25519.
- **Method (alpha):** `did:key` only. Resolution is deterministic public-key
extraction from the multicodec prefix (`z6Mk…`, multicodec `0xed01`).
- Signed requests presenting any other DID method are rejected; the node's
auth layer answers with a hint that only `did:key` is supported in alpha.
- *Planned:* `did:web` and `did:gitlawb` resolution. Clients MUST NOT assume
either is accepted anywhere yet.
## 2. Authentication (RFC 9421)
- **Transport:** signed requests are expected over TLS; the node does not
terminate HTTP signatures over cleartext.
- **HTTP signatures:** `Signature-Input` covers `@method`, `@path`, and
`content-digest`:
`sig1=("@method" "@path" "content-digest");keyid="did:key:…";alg="ed25519";created=<unix>`
All three components are mandatory: a `Signature-Input` missing one is
rejected as `incomplete_signature`.
- **`@path` value:** the node binds `@path` to the path *and* query string of
the request, not to the path-only value RFC 9421 defines for `@path`. A
second implementation must sign `GET /r/info/refs?service=git-upload-pack`
including the `?service=…` part.
- **Signer identity:** `keyid` is the actor DID (`did:key` only, see §1).
- **Integrity:** `Content-Digest` is SHA-256 over the request body and is
verified when the header is present. Because `content-digest` is a covered
component, every signed request signs a digest value: on a body-less
request the signed value is the SHA-256 of the empty body, which the node
also accepts when the header is absent entirely.
## 3. Proof of Intelligence (iCaptcha)
- **Mode:** controlled by `ICAPTCHA_MODE` = `off` | `shadow` | `enforce`,
**default `off`**. A node with the gate off never demands proof.
- **Scope:** the gate applies to a small set of write endpoints (agent
registration and repo create/fork), not to all writes.
- **Wire shape:** the client-supplied header is `x-icaptcha-proof` — that is
the only iCaptcha header a node reads from a request. When proof is
required and missing/invalid, the node answers
`403 icaptcha_proof_required` and sets `x-icaptcha-url` and
`x-icaptcha-level` **on the response** (also mirrored as JSON fields) so
the client can go solve a challenge and retry.
## 4. Ref-Update Certificate
*Planned:* the `gitlawb/ref-update/v1` schema below is defined in code, but no
shipped endpoint serves or verifies these certificates today; nothing in
sections 2-3 produces one on the wire. It is documented here so a second
implementation shares the type vocabulary ahead of the wire surface.
The hash fields reserve 64 hex characters for the SHA-256 object format; the
repositories the node serves are sha1 with 40-hex OIDs (see §5), so the field
width is a forward reservation, not what a current push produces.
Two distinct certificate-shaped payloads exist; this section documents the
**core signing schema**, which is what `gitlawb/ref-update/v1` names:
- **Type string:** `gitlawb/ref-update/v1`.
- **Body (canonical JSON, the signed bytes):** `type`, `repo` (the
repository DID), `ref_name`, `from` (previous hash, 64 hex chars,
all-zeros for a new ref), `to` (target hash, 64 hex chars), `seq`
(monotonically increasing, replay prevention), `timestamp` (RFC 3339),
`nonce`.
- **Signatures:** a list of `{signer: did:key, sig: base64url-unpadded
Ed25519}` entries appended outside the signed body; thresholds are counted
over distinct signers that verify.
The node's REST API separately serves **push receipts** — a different,
node-issued JSON payload attesting that a push was processed. Receipts are
not `gitlawb/ref-update/v1` documents and are not interchangeable with them;
they are documented with the node's HTTP API, not here.
## 5. Storage & Git Transport
- **Object format:** bare repositories are created with
`--object-format=sha1`; production OIDs are 40 hex chars. *Planned:*
SHA-256 object format.
- **Smart-HTTP endpoints:**
- `GET /{owner}/{repo}/info/refs?service=git-upload-pack` — fetch
advertisement.
- `GET /{owner}/{repo}/info/refs?service=git-receive-pack` — push
advertisement.
The `?service=` parameter is mandatory and takes exactly those two
values; a missing parameter is a `400`, not an omitted-service default.
- `POST /{owner}/{repo}/git-upload-pack` — clone/fetch. Request and
response bodies are pkt-line framed; the result content type is
`application/x-git-upload-pack-result`.
- `POST /{owner}/{repo}/git-receive-pack` — push. **Authentication is
mandatory**: an unsigned push is refused with `401`. Same pkt-line
framing; the result content type is
`application/x-git-receive-pack-result`.
Signed requests on this path follow §2, including TLS transport.
- **Fetch vs push auth split:** fetch-side requests against a withheld or
private repo answer `404` (existence is not confirmed to unauthorized
callers), while the push path answers `401` for missing/invalid
signatures. A second implementation must not treat the two symmetrically.
- **Content addressing:** the node records IPFS CID metadata per branch and
serves it over REST (the repo `refs` endpoint maps branch heads to CIDs).
*Planned:* IPNS-backed branch pointers; IPNS is not part of the shipped
wire contract.
-1
View File
@@ -40,7 +40,6 @@ cid = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
chacha20poly1305 = "0.10"
http-body-util = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
dirs-next = "2"
-4
View File
@@ -95,7 +95,6 @@ pub async fn create_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.opened",
serde_json::json!({
@@ -247,7 +246,6 @@ pub async fn merge_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.merged",
serde_json::json!({
@@ -296,7 +294,6 @@ pub async fn close_pr(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.closed",
serde_json::json!({
@@ -347,7 +344,6 @@ pub async fn create_review(
webhooks::fire_event(
std::sync::Arc::clone(&state.db),
std::sync::Arc::clone(&state.http_client),
std::sync::Arc::clone(&state.webhook_secret_key),
&record.id,
"pull_request.reviewed",
serde_json::json!({
+13 -146
View File
@@ -56,14 +56,7 @@ pub async fn create_webhook(
id: Uuid::new_v4().to_string(),
repo_id: record.id,
url: req.url,
// Seal the secret before it touches the database (#506): the column and
// any dump/backup of it hold ciphertext, never the caller's signing key.
secret: req.secret.map(|plain| {
crate::webhook_secret::WebhookSecret::new(crate::webhook_secret::seal(
state.webhook_secret_key.as_ref(),
&plain,
))
}),
secret: req.secret,
events,
created_by_did,
created_at: Utc::now().to_rfc3339(),
@@ -100,11 +93,19 @@ pub async fn list_webhooks(
crate::api::require_repo_owner(&record, &caller)?;
let hooks = state.db.list_webhooks(&record.id).await?;
// `WebhookSecret` redacts itself on serialize, so a set secret is emitted as
// "***" and the signing material never appears in the body (see webhook_secret).
let count = hooks.len();
// Redact secrets in list response
let redacted: Vec<_> = hooks
.into_iter()
.map(|mut h| {
if h.secret.is_some() {
h.secret = Some("***".into());
}
h
})
.collect();
Ok(Json(
serde_json::json!({ "webhooks": hooks, "count": count }),
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
))
}
@@ -148,7 +149,6 @@ mod tests {
use crate::db::RepoRecord;
use crate::error::AppError;
use crate::test_support::test_state;
use rand::Rng;
// create_webhook gates req.url through is_public_http_url. Pin the exact
// SSRF targets from issue #81 so the webhook path can never regress to the
@@ -226,137 +226,4 @@ mod tests {
"a rejected .localhost webhook must leave no row behind"
);
}
/// The startup re-encryption pass must seal any webhook secret still resting
/// as plaintext (a row written before at-rest encryption shipped), and leave
/// a second pass with nothing to do.
#[sqlx::test]
async fn reencrypt_legacy_secrets_encrypts_existing_cleartext(pool: PgPool) {
let state = test_state(pool).await;
// Seed a webhook the way a pre-encryption node would have: plaintext
// secret written straight into the row, bypassing the handler's seal.
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/legacy-repo".to_string(),
url: "https://hooks.example.com/legacy".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(
"legacy-plaintext".to_string(),
)),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 1, "exactly the one plaintext row is rewritten");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.expect("secret retained after re-encryption");
assert!(secret.is_sealed(), "re-encrypted secret must be sealed");
assert_eq!(
crate::webhook_secret::open(state.webhook_secret_key.as_ref(), secret.as_str())
.as_deref(),
Some("legacy-plaintext"),
"the node-held key must recover the original secret"
);
// Idempotent: a second pass finds nothing left to rewrite.
let again = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(again, 0);
}
/// A legacy plaintext secret that literally begins with the seal prefix is
/// not ciphertext: prefix-matching alone would skip it forever while
/// `reveal` fails to open it, wedging the hook. Classification is by
/// `open`, so the pass seals it and the literal secret survives.
#[sqlx::test]
async fn reencrypt_seals_a_prefixed_plaintext_secret(pool: PgPool) {
let state = test_state(pool).await;
let literal = format!(
"{}not-a-real-ciphertext",
crate::webhook_secret::SEAL_PREFIX
);
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/prefixed-repo".to_string(),
url: "https://hooks.example.com/prefixed".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(literal.clone())),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 1, "the prefixed plaintext row is rewritten");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.unwrap();
assert!(secret.is_sealed());
assert_eq!(
crate::webhook_secret::reveal(state.webhook_secret_key.as_ref(), &secret).as_deref(),
Some(literal.as_str()),
"the literal plaintext, prefix included, is the recovered secret"
);
}
/// A well-formed sealed payload that does not open under this node's key
/// (foreign or rotated ciphertext) must survive the re-encrypt pass
/// untouched: re-wrapping it would make `reveal` return the outer blob as
/// a signing secret and flip a broken hook from withheld into garbage
/// signatures.
#[sqlx::test]
async fn reencrypt_leaves_foreign_ciphertext_untouched(pool: PgPool) {
let state = test_state(pool).await;
let foreign_key: [u8; 32] = chacha20poly1305::aead::OsRng.gen();
let foreign_ciphertext = crate::webhook_secret::seal(&foreign_key, "owner-real-secret");
let hook = crate::db::Webhook {
id: Uuid::new_v4().to_string(),
repo_id: "owner/foreign-repo".to_string(),
url: "https://hooks.example.com/foreign".to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(
foreign_ciphertext.clone(),
)),
events: vec!["*".to_string()],
created_by_did: "did:key:zLEGACYOWNER".to_string(),
created_at: Utc::now().to_rfc3339(),
active: true,
};
state.db.create_webhook(&hook).await.unwrap();
let rewritten = state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
.unwrap();
assert_eq!(rewritten, 0, "foreign ciphertext is left unchanged");
let stored = state.db.get_webhook(&hook.id).await.unwrap().unwrap();
let secret = stored.secret.unwrap();
assert_eq!(
secret.as_str(),
foreign_ciphertext,
"the stored value is still the unopenable ciphertext, not a re-wrap"
);
assert!(
crate::webhook_secret::reveal(state.webhook_secret_key.as_ref(), &secret).is_none(),
"delivery still withholds: reveal cannot open the foreign blob"
);
}
}
-2
View File
@@ -724,7 +724,6 @@ mod tests {
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let webhook_secret_key = crate::state::AppState::derive_webhook_secret_key(&keypair);
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
@@ -764,7 +763,6 @@ mod tests {
ipfs_max_legacy_scan_rule_bytes:
crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
webhook_secret_key: Arc::new(webhook_secret_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
+3 -69
View File
@@ -8,8 +8,6 @@ use sqlx::{postgres::PgPoolOptions, PgPool, Row};
use tracing::info;
use uuid::Uuid;
use crate::webhook_secret::WebhookSecret;
/// Maximum visible repositories per page, plus one internal look-ahead row.
pub(crate) const MAX_VISIBLE_REPO_PAGE_SIZE: usize = 200;
@@ -299,10 +297,7 @@ pub struct Webhook {
pub id: String,
pub repo_id: String,
pub url: String,
/// At rest this holds the sealed ciphertext (`enc:v1:…`) produced by
/// [`crate::webhook_secret::seal`]; the newtype redacts `Debug` and
/// `Serialize` so the signing secret never reaches a log line or a body.
pub secret: Option<WebhookSecret>,
pub secret: Option<String>,
pub events: Vec<String>,
pub created_by_did: String,
pub created_at: String,
@@ -3883,7 +3878,7 @@ impl Db {
.bind(&hook.id)
.bind(&hook.repo_id)
.bind(&hook.url)
.bind(hook.secret.as_ref().map(|s| s.as_str()))
.bind(&hook.secret)
.bind(&events_json)
.bind(&hook.created_by_did)
.bind(&hook.created_at)
@@ -3934,67 +3929,6 @@ impl Db {
.filter(|h| h.active && h.events.iter().any(|e| e == "*" || e == event))
.collect())
}
/// One-time re-encryption of webhook secrets that still rest as plaintext
/// (rows written before at-rest encryption shipped). Runs once at startup; a
/// schema migration cannot do this because the node-held key never reaches
/// Postgres. Returns the number of rows rewritten.
///
/// "Already sealed" means "opens under the node key", not "has the prefix":
/// a plaintext secret that literally begins with the `enc:v1:` prefix, or a
/// value sealed under another identity, fails `open` and is re-sealed raw,
/// which preserves whatever the stored value was rather than wedging it.
/// A prefixed value that does not open is also warned on, since it is more
/// likely corrupt ciphertext than a colliding plaintext.
pub async fn reencrypt_legacy_webhook_secrets(&self, key: &[u8; 32]) -> Result<usize> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT id, secret FROM webhooks WHERE secret IS NOT NULL")
.fetch_all(&self.pool)
.await?;
let mut rewritten = 0usize;
for (id, stored) in rows {
if crate::webhook_secret::open(key, &stored).is_some() {
continue; // already sealed under this node's key
}
if crate::webhook_secret::has_seal_shape(&stored) {
// Well-formed sealed payload that does not open under this
// node's key: foreign or rotated ciphertext. Re-wrapping the
// outer blob as plaintext would make `reveal` return it as a
// signing secret and flip Withhold into a garbage signature,
// so leave the row unchanged: delivery keeps withholding.
tracing::warn!(
webhook_id = %id,
"webhook secret is well-formed ciphertext but does not open under the node key; leaving it unchanged"
);
continue;
}
if stored.starts_with(crate::webhook_secret::SEAL_PREFIX) {
tracing::warn!(
webhook_id = %id,
"webhook secret has the sealed prefix but is not well-formed ciphertext; sealing the raw value as plaintext"
);
}
let sealed = crate::webhook_secret::seal(key, &stored);
// Compare-and-set on the value read above: a concurrent secret
// update between SELECT and UPDATE must not be clobbered.
let result =
sqlx::query("UPDATE webhooks SET secret = $2 WHERE id = $1 AND secret = $3")
.bind(&id)
.bind(sealed)
.bind(&stored)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(
webhook_id = %id,
"webhook secret changed between read and re-encrypt; skipping"
);
}
rewritten += result.rows_affected() as usize;
}
Ok(rewritten)
}
}
// ── Ref Certificates ──────────────────────────────────────────────────────────
@@ -9230,7 +9164,7 @@ fn row_to_webhook(r: sqlx::postgres::PgRow) -> Webhook {
id: r.get("id"),
repo_id: r.get("repo_id"),
url: r.get("url"),
secret: r.get::<Option<String>, _>("secret").map(WebhookSecret::new),
secret: r.get("secret"),
events,
created_by_did: r.get("created_by_did"),
created_at: r.get("created_at"),
@@ -1439,7 +1439,6 @@ async fn run_effect_bundle(
crate::webhooks::fire_event_occurrence(
state.db.clone(),
state.http_client.clone(),
state.webhook_secret_key.clone(),
&repo.id,
"push",
payload,
@@ -1471,7 +1470,6 @@ async fn run_effect_bundle(
crate::webhooks::fire_event_occurrence_with_claimed_hooks(
state.db.clone(),
state.http_client.clone(),
state.webhook_secret_key.clone(),
&repo.id,
"push",
payload,
-20
View File
@@ -27,7 +27,6 @@ mod sync;
mod test_support;
mod upstream_mirror;
mod visibility;
mod webhook_secret;
mod webhooks;
use anyhow::{anyhow, Context, Result};
@@ -100,7 +99,6 @@ async fn main() -> Result<()> {
// Sealing key for the legacy-scan continuation tokens, DERIVED from the identity
// just loaded so it is the same key after a restart (see `derive_scan_token_key`).
let scan_token_key = AppState::derive_scan_token_key(&keypair);
let webhook_secret_key = AppState::derive_webhook_secret_key(&keypair);
let node_did = keypair.did();
// One-time metrics init. Must run before any handler that calls into
@@ -483,7 +481,6 @@ async fn main() -> Result<()> {
ipfs_max_legacy_scan_rows: AppState::ipfs_legacy_scan_row_budget(&config),
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
webhook_secret_key: Arc::new(webhook_secret_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust,
sync_trigger_rate_limiter,
@@ -572,23 +569,6 @@ async fn main() -> Result<()> {
),
git_bin: "git".to_string(),
};
// One-time re-encryption of any webhook HMAC secrets still resting as
// plaintext (written before at-rest encryption shipped). A SQL migration
// cannot do it — Postgres never sees the node-held key — so it runs here.
match state
.db
.reencrypt_legacy_webhook_secrets(state.webhook_secret_key.as_ref())
.await
{
Ok(0) => {}
Ok(n) => info!(
count = n,
"re-encrypted legacy webhook HMAC secrets at rest"
),
Err(e) => warn!(err = %e, "failed to re-encrypt legacy webhook secrets"),
}
if config.ipfs_rate_limit == 0 {
tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled");
}
+2 -4
View File
@@ -342,11 +342,9 @@ pub fn record_sync_processed(status: &str) {
}
/// Record one webhook delivery attempt outcome. `result` ∈
/// {ok, http_error, network_error, skipped, invalid_url, secret_unopenable}.
/// {ok, http_error, network_error, skipped, invalid_url}.
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
/// check at delivery time and the POST was never sent. `secret_unopenable` is
/// a withheld delivery: the hook carries a secret the node could not open, so
/// the POST was never sent rather than going out unsigned.
/// check at delivery time and the POST was never sent.
pub fn record_webhook_delivery(result: &str) {
if let Some(c) = WEBHOOK_DELIVERIES.get() {
c.with_label_values(&[result]).inc();
+8 -54
View File
@@ -21,19 +21,6 @@ const SCAN_TOKEN_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/ipfs-scan-token";
/// front) and needs no migration, no config, and no change to this function.
const SCAN_TOKEN_KEY_INFO: &[u8] = b"gitlawb/ipfs-scan-token/v1";
/// HKDF salt for [`AppState::derive_webhook_secret_key`]. A constant, not a
/// secret: the confidentiality of the derived key rests on the node's private
/// seed being the input keying material, exactly as for the scan-token key.
const WEBHOOK_SECRET_KEY_SALT: &[u8] = b"gitlawb/hkdf-salt/webhook-secret";
/// HKDF `info` for [`AppState::derive_webhook_secret_key`]: the domain
/// separation from the scan-token key that shares the same input keying
/// material. Unlike the scan-token key, bumping the version is NOT a safe
/// rotation: sealed secrets persist, `open` has no old-key fallback, and a new
/// version renders every stored ciphertext permanently unopenable until a
/// re-seal path exists.
const WEBHOOK_SECRET_KEY_INFO: &[u8] = b"gitlawb/webhook-secret/v1";
#[derive(Clone, Debug)]
pub struct RefUpdateBroadcast {
pub repo: String,
@@ -186,11 +173,6 @@ pub struct AppState {
/// already has on disk, so derivation is also the smaller mechanism. Rotation is a
/// bump of the version in [`SCAN_TOKEN_KEY_INFO`].
pub ipfs_scan_token_key: Arc<[u8; 32]>,
/// Key sealing webhook HMAC secrets at rest (#506), derived from the node's
/// persistent identity by [`AppState::derive_webhook_secret_key`]. The
/// webhook delivery path opens a secret with it only at signing time; the
/// `webhooks.secret` column stores the ciphertext (see `webhook_secret`).
pub webhook_secret_key: Arc<[u8; 32]>,
/// Hard ceiling on the byte size of an object `GET /ipfs/{cid}` will buffer and
/// serve (default `api::ipfs::MAX_SERVED_OBJECT_BYTES`). The serve reads via a
/// blocking `git cat-file` and buffers the whole object; without a bound a large
@@ -449,48 +431,20 @@ impl AppState {
/// identity, the token format, or any caller, so rotation is a constant change
/// rather than a fork of this derivation.
pub(crate) fn derive_scan_token_key(keypair: &Keypair) -> [u8; 32] {
Self::hkdf_sha256(
SCAN_TOKEN_KEY_SALT,
SCAN_TOKEN_KEY_INFO,
keypair.to_seed().as_slice(),
)
}
/// Derive the at-rest webhook-secret sealing key from the node identity.
///
/// Same HKDF construction as [`AppState::derive_scan_token_key`] under its
/// own [`WEBHOOK_SECRET_KEY_INFO`]/[`WEBHOOK_SECRET_KEY_SALT`] pair, so it
/// is derived (a restart re-derives the same key, and old secrets remain
/// readable) yet domain-separated from the signing key and the scan-token
/// key. The doc on [`SCAN_TOKEN_KEY_INFO`] argues why derivation (not a
/// boot-random key) is the right trade: the secret's confidentiality still
/// rests on the node's private seed. Rotation is NOT a version bump here,
/// unlike for the scan-token key: [`WEBHOOK_SECRET_KEY_INFO`] explains that
/// bumping renders every stored ciphertext unopenable until a re-seal path
/// exists.
pub(crate) fn derive_webhook_secret_key(keypair: &Keypair) -> [u8; 32] {
Self::hkdf_sha256(
WEBHOOK_SECRET_KEY_SALT,
WEBHOOK_SECRET_KEY_INFO,
keypair.to_seed().as_slice(),
)
}
/// HKDF-Extract-then-Expand (RFC 5869) over the node seed: PRK =
/// HMAC(salt, ikm), then one expand block T(1) = HMAC(PRK, info || 0x01).
/// One 32-byte output needs exactly one block of SHA-256, so there is no
/// counter loop to get wrong. Salt and info are the caller's domain
/// separation; shared by every identity-derived key in this module.
fn hkdf_sha256(salt: &[u8], info: &[u8], ikm: &[u8]) -> [u8; 32] {
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<sha2::Sha256>;
let mut extract = HmacSha256::new_from_slice(salt).expect("HMAC takes a key of any size");
extract.update(ikm);
let seed = keypair.to_seed();
// HKDF-Extract: PRK = HMAC(salt, ikm).
let mut extract =
HmacSha256::new_from_slice(SCAN_TOKEN_KEY_SALT).expect("HMAC takes a key of any size");
extract.update(seed.as_slice());
let prk = extract.finalize().into_bytes();
// HKDF-Expand, one block: T(1) = HMAC(PRK, info || 0x01). One 32-byte output
// needs exactly one block of SHA-256, so there is no counter loop to get wrong.
let mut expand =
HmacSha256::new_from_slice(prk.as_slice()).expect("HMAC takes a key of any size");
expand.update(info);
expand.update(SCAN_TOKEN_KEY_INFO);
expand.update(&[0x01]);
expand.finalize().into_bytes().into()
}
+1 -5
View File
@@ -83,7 +83,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let webhook_secret_key = crate::state::AppState::derive_webhook_secret_key(&keypair);
let node_did = keypair.did();
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
@@ -119,7 +118,6 @@ fn build_state(db: Arc<crate::db::Db>, pool: PgPool) -> AppState {
ipfs_max_legacy_scan_rows: crate::api::ipfs::MAX_LEGACY_SCAN_ROWS_PER_REQUEST,
ipfs_max_legacy_scan_rule_bytes: crate::api::ipfs::MAX_LEGACY_SCAN_RULE_BYTES_PER_REQUEST,
ipfs_scan_token_key: Arc::new(scan_token_key),
webhook_secret_key: Arc::new(webhook_secret_key),
ipfs_max_served_object_bytes: crate::api::ipfs::MAX_SERVED_OBJECT_BYTES,
push_limiter_trust: crate::rate_limit::TrustedProxy::None,
sync_trigger_rate_limiter: RateLimiter::new(60, Duration::from_secs(3600)),
@@ -4207,9 +4205,7 @@ mod tests {
id: uuid::Uuid::new_v4().to_string(),
repo_id: repo.id.clone(),
url: secret_url.to_string(),
secret: Some(crate::webhook_secret::WebhookSecret::new(
"topsecret".to_string(),
)),
secret: Some("topsecret".to_string()),
events: vec!["*".to_string()],
created_by_did: owner.to_string(),
created_at: Utc::now().to_rfc3339(),
-221
View File
@@ -1,221 +0,0 @@
//! At-rest protection for webhook HMAC secrets (#506).
//!
//! Webhook secrets are signing keys: whoever holds one can forge delivery
//! payloads to the receiver the hook points at. They are persisted in the
//! `webhooks.secret` column and used to compute the `X-Gitlawb-Signature-256`
//! header on each delivery. That secret needs two properties:
//!
//! * It must never reach a log line or an API response. [`WebhookSecret`]
//! redacts both `Debug` and `Serialize`, so no derived `{:?}` and no JSON
//! body ever renders the value, whatever it contains.
//! * It must not rest as plaintext where a database dump or backup can read
//! it. The node seals the secret with a key derived from its persistent
//! identity (see `AppState::derive_webhook_secret_key`), so a dump alone
//! reveals nothing.
//!
//! Storage format: `enc:v1:<base64url(nonce || ciphertext)>` — a 24-byte
//! XChaCha20-Poly1305 nonce followed by the ciphertext (which carries the tag).
//! Anything without the `enc:v1:` prefix is a legacy plaintext value, written
//! before this protection shipped; the delivery path signs with those unchanged
//! and they are re-encrypted once at startup by
//! `Db::reencrypt_legacy_webhook_secrets`.
use std::fmt;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit, OsRng},
XChaCha20Poly1305, XNonce,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Prefix marking a sealed-at-rest secret. Legacy plaintext values lack it.
pub const SEAL_PREFIX: &str = "enc:v1:";
const NONCE_LEN: usize = 24;
/// An HMAC signing secret that never renders its contents in debug or JSON
/// output. Used as `Webhook.secret`, so that field is safe to `Debug`-print and
/// `Serialize` by construction rather than by discipline at each call site.
#[derive(Clone, PartialEq, Eq)]
pub struct WebhookSecret(String);
impl WebhookSecret {
pub fn new(inner: String) -> Self {
Self(inner)
}
pub fn as_str(&self) -> &str {
&self.0
}
/// Whether the value is a sealed ciphertext (as opposed to legacy plaintext).
pub fn is_sealed(&self) -> bool {
self.0.starts_with(SEAL_PREFIX)
}
}
impl fmt::Debug for WebhookSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("\"***\"")
}
}
impl Serialize for WebhookSecret {
/// Emit a fixed marker, never the contents. On the wire a set secret is
/// `"secret": "***"` and an unset one is `"secret": null` (`Option`), which
/// the CLI already reads as "is a secret set".
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("***")
}
}
impl<'de> Deserialize<'de> for WebhookSecret {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(WebhookSecret(String::deserialize(deserializer)?))
}
}
/// Seal a plaintext secret under a node-held key. A fresh `OsRng` nonce on every
/// seal guarantees repeated seals of the same secret never collide.
pub fn seal(key: &[u8; 32], plaintext: &str) -> String {
let cipher = XChaCha20Poly1305::new_from_slice(key).expect("webhook secret key is 32 bytes");
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.expect("XChaCha20-Poly1305 encryption is infallible for in-memory inputs");
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
format!("{SEAL_PREFIX}{}", B64URL.encode(out))
}
/// Open a sealed secret. Returns `None` for anything that is not a well-formed
/// sealed value — legacy plaintext, a foreign key, tampered or truncated bytes —
/// so a caller that needs the legacy path checks [`WebhookSecret::is_sealed`]
/// first (see [`reveal`]).
pub fn open(key: &[u8; 32], stored: &str) -> Option<String> {
let raw = B64URL.decode(stored.strip_prefix(SEAL_PREFIX)?).ok()?;
if raw.len() <= NONCE_LEN {
return None;
}
let (nonce, ciphertext) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
let plaintext = cipher.decrypt(XNonce::from_slice(nonce), ciphertext).ok()?;
String::from_utf8(plaintext).ok()
}
/// Whether a stored value has the structural shape of a sealed payload:
/// [`SEAL_PREFIX`] plus a base64url blob long enough to hold a nonce and
/// ciphertext. This is a shape check, not an authentication check: a
/// well-formed blob sealed under a different key satisfies it while [`open`]
/// still fails. The re-encryption pass uses the distinction to leave
/// well-formed-but-foreign ciphertext alone instead of re-wrapping it as
/// plaintext, which would let `reveal` return the outer blob as a signing key.
pub fn has_seal_shape(stored: &str) -> bool {
stored
.strip_prefix(SEAL_PREFIX)
.and_then(|rest| B64URL.decode(rest).ok())
.is_some_and(|raw| raw.len() > NONCE_LEN)
}
/// Resolve a stored secret to its plaintext for signing. Sealed values are opened
/// with the node key; legacy plaintext is returned unchanged so a hook created
/// before this fix keeps signing until the startup re-encryption pass runs.
pub fn reveal(key: &[u8; 32], stored: &WebhookSecret) -> Option<String> {
if stored.is_sealed() {
open(key, stored.as_str())
} else {
Some(stored.as_str().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::Rng;
/// Test keys need no secrecy, but they still come from the OS CSPRNG so no
/// hard-coded key material sits in the tree. A wrong key fails XChaCha20-Poly1305
/// authentication with overwhelming probability, so the "wrong key" case stays
/// deterministic in practice.
fn new_key() -> [u8; 32] {
OsRng.gen()
}
fn other_key() -> [u8; 32] {
OsRng.gen()
}
#[test]
fn seal_open_round_trip() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
assert!(sealed.starts_with(SEAL_PREFIX));
assert_eq!(open(&k, &sealed).as_deref(), Some("s3cr3t-value"));
}
#[test]
fn sealed_value_is_never_the_plaintext() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
assert!(!sealed.contains("s3cr3t-value"));
}
#[test]
fn wrong_key_does_not_open() {
let sealed = seal(&new_key(), "s3cr3t-value");
assert_eq!(open(&other_key(), &sealed), None);
}
#[test]
fn tampered_or_truncated_value_does_not_open() {
let k = new_key();
let sealed = seal(&k, "s3cr3t-value");
// Flip a byte inside the base64 payload.
let mut chars: Vec<char> = sealed.chars().collect();
let idx = SEAL_PREFIX.len();
chars[idx] = if chars[idx] == 'A' { 'B' } else { 'A' };
let tampered: String = chars.into_iter().collect();
assert_eq!(open(&k, &tampered), None);
// A value that is just the prefix (no nonce/ciphertext) is malformed.
assert_eq!(open(&k, SEAL_PREFIX), None);
}
#[test]
fn reveal_opens_a_sealed_value() {
let k = new_key();
let sealed = WebhookSecret::new(seal(&k, "s3cr3t-value"));
assert_eq!(reveal(&k, &sealed).as_deref(), Some("s3cr3t-value"));
}
#[test]
fn legacy_plaintext_is_revealed_unchanged() {
let k = new_key();
let legacy = WebhookSecret::new("plaintext-secret".into());
assert!(!legacy.is_sealed());
assert_eq!(reveal(&k, &legacy).as_deref(), Some("plaintext-secret"));
}
#[test]
fn debug_is_redacted() {
let s = WebhookSecret::new("s3cr3t-value".into());
let rendered = format!("{s:?}");
assert!(!rendered.contains("s3cr3t-value"));
assert_eq!(rendered, "\"***\"");
}
#[test]
fn serialize_is_redacted() {
let s = WebhookSecret::new("s3cr3t-value".into());
let json = serde_json::to_string(&s).unwrap();
assert!(!json.contains("s3cr3t-value"));
assert_eq!(json, "\"***\"");
}
#[test]
fn deserialize_reads_the_underlying_string() {
let s: WebhookSecret = serde_json::from_str("\"s3cr3t-value\"").unwrap();
assert_eq!(s.as_str(), "s3cr3t-value");
}
}
+2 -122
View File
@@ -30,50 +30,15 @@ fn sign_payload(secret: &str, payload: &[u8]) -> String {
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
/// What the signing step decided for one hook. Keeping this a decision (not a
/// bare `Option`) lets the delivery path tell "hook has no secret" (send
/// unsigned) apart from "hook has a secret that could not be opened" (withhold:
/// an unsigned POST would present a broken hook as working while the ledger
/// marks the event delivered).
enum SigningOutcome {
NoSecret,
Signed(String),
Withhold,
}
fn signing_decision(
secret_key: &[u8; 32],
secret: Option<&crate::webhook_secret::WebhookSecret>,
bytes: &[u8],
) -> SigningOutcome {
match secret {
None => SigningOutcome::NoSecret,
Some(s) => match crate::webhook_secret::reveal(secret_key, s) {
Some(plain) => SigningOutcome::Signed(sign_payload(&plain, bytes)),
None => SigningOutcome::Withhold,
},
}
}
/// Fire webhooks for `event` on `repo_id`. Spawns background tasks — never blocks.
pub fn fire_event(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
) {
fire_event_occurrence(
db,
http_client,
secret_key,
repo_id,
event,
payload,
None,
None,
);
fire_event_occurrence(db, http_client, repo_id, event, payload, None, None);
}
/// Occurrence-keyed variant: at most one delivery per
@@ -85,11 +50,9 @@ pub fn fire_event(
/// to claim deliveries synchronously, then call
/// `fire_event_occurrence_with_claimed_hooks` with the claimed hook IDs to
/// spawn HTTP POST only for claimed hooks.
#[allow(clippy::too_many_arguments)]
pub fn fire_event_occurrence(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
@@ -104,7 +67,6 @@ pub fn fire_event_occurrence(
fire_event_async_occurrence(
db,
http_client,
secret_key,
&repo_id,
&event,
payload,
@@ -121,7 +83,6 @@ pub fn fire_event_occurrence(
pub fn fire_event_occurrence_with_claimed_hooks(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
@@ -137,7 +98,6 @@ pub fn fire_event_occurrence_with_claimed_hooks(
fire_event_async_occurrence(
db,
http_client,
secret_key,
&repo_id,
&event,
payload,
@@ -203,7 +163,6 @@ pub async fn claim_webhook_delivery_before_spawn(
async fn fire_event_async_occurrence(
db: Arc<Db>,
http_client: Arc<reqwest::Client>,
secret_key: Arc<[u8; 32]>,
repo_id: &str,
event: &str,
payload: serde_json::Value,
@@ -265,7 +224,6 @@ async fn fire_event_async_occurrence(
}
let db_for_mark = db.clone();
let secret_key = Arc::clone(&secret_key);
tokio::spawn(async move {
// Policy skip, not a delivery failure: the stored URL predates the
// create-time gate. The "invalid_url" label is documented on
@@ -281,28 +239,7 @@ async fn fire_event_async_occurrence(
}
let delivery_for_mark = delivery_id.clone();
// Open the secret only at signing time; at rest it stays sealed.
let signature =
match signing_decision(secret_key.as_ref(), hook.secret.as_ref(), &bytes) {
SigningOutcome::NoSecret => None,
SigningOutcome::Signed(sig) => Some(sig),
// A hook that HAS a secret must sign with it or not deliver at
// all: `reveal` returning None means the stored value could
// not be opened (a foreign or rotated key, a truncated
// ciphertext), and sending unsigned would present a broken
// hook as a working one while the ledger marks the event
// delivered. The row stays pending, like a network error, so
// a fixed key can still land it.
SigningOutcome::Withhold => {
crate::metrics::record_webhook_delivery("secret_unopenable");
tracing::warn!(
hook_id = %hook.id,
delivery_id = %delivery_for_mark,
"webhook secret could not be opened; delivery withheld"
);
return;
}
};
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
let mut req = client
.post(&hook.url)
@@ -347,60 +284,3 @@ async fn fire_event_async_occurrence(
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::Rng;
fn key() -> [u8; 32] {
chacha20poly1305::aead::OsRng.gen()
}
/// A hook with no secret delivers unsigned, as before.
#[test]
fn signing_decision_no_secret_is_unsigned() {
assert!(matches!(
signing_decision(&key(), None, b"{}"),
SigningOutcome::NoSecret
));
}
/// A sealed secret that opens signs with the revealed plaintext.
#[test]
fn signing_decision_sealed_secret_signs() {
let k = key();
let sealed =
crate::webhook_secret::WebhookSecret::new(crate::webhook_secret::seal(&k, "topsecret"));
match signing_decision(&k, Some(&sealed), b"{}") {
SigningOutcome::Signed(sig) => {
assert_eq!(sig, sign_payload("topsecret", b"{}"))
}
_ => panic!("an openable sealed secret must produce a signature"),
}
}
/// A stored value that carries the sealed prefix but cannot be opened
/// (foreign key, truncated ciphertext) must withhold, never send unsigned.
#[test]
fn signing_decision_unopenable_secret_withholds() {
let garbage =
crate::webhook_secret::WebhookSecret::new("enc:v1:bm90LWNoaXBoZXJ0ZXh0".to_string());
assert!(matches!(
signing_decision(&key(), Some(&garbage), b"{}"),
SigningOutcome::Withhold
));
}
/// A legacy plaintext value signs with itself unchanged.
#[test]
fn signing_decision_legacy_plaintext_signs() {
let legacy = crate::webhook_secret::WebhookSecret::new("plainsecret".to_string());
match signing_decision(&key(), Some(&legacy), b"{}") {
SigningOutcome::Signed(sig) => {
assert_eq!(sig, sign_payload("plainsecret", b"{}"))
}
_ => panic!("legacy plaintext must keep signing"),
}
}
}