mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 17:57:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a576ea030 | ||
|
|
73a14d556c | ||
|
|
4fc041a399 | ||
|
|
3a0d91a801 | ||
|
|
d15f194f7f | ||
|
|
f67688eae2 | ||
|
|
af6a759330 | ||
|
|
66fe646320 | ||
|
|
31343d25af | ||
|
|
124f332809 | ||
|
|
3b00725fd3 | ||
|
|
6c3f577842 |
No files matched your search
+13
-4
@@ -6,8 +6,16 @@
|
||||
# Path to the node's Ed25519 keypair PEM file. Must name a key file, not a
|
||||
# directory: a trailing slash, a final `.` or `..`, and `..` traversal are
|
||||
# refused so the path cannot retarget a parent. A `~/` value must stay under
|
||||
# home after expansion. Bare `identity.pem` is still legal.
|
||||
# home after expansion, and the path must be absolute: its parent is also
|
||||
# the delegation store that `gl ucan import` writes and `git-remote-gitlawb`
|
||||
# reads, and the two do not share a working directory, so a relative path
|
||||
# sends them to different places. Bare `identity.pem` under the default
|
||||
# config dir is still legal.
|
||||
# Generate with: gl identity new
|
||||
# Leaving it unset uses ~/.gitlawb/identity.pem, which is the shared default
|
||||
# for both `gl` and `git-remote-gitlawb`. The bare-filename form is for the
|
||||
# node's own service config; client tooling should inherit the default or use
|
||||
# an absolute path.
|
||||
GITLAWB_KEY=/data/keys/identity.pem
|
||||
|
||||
# Path to the node's persistent libp2p identity key file. Must include a
|
||||
@@ -152,9 +160,10 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
|
||||
# push to every repository, private ones included. On by default.
|
||||
#
|
||||
# Set to false only for a rolling upgrade whose pushers are not yet the repo
|
||||
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
|
||||
# verified but not yet honored for authorization, so they cannot push while this
|
||||
# is on. See docs/RUN-A-NODE.md.
|
||||
# owner. A holder of an owner-rooted, time-bounded git/push UCAN naming that
|
||||
# repository can push while this is on — see docs/RUN-A-NODE.md for issuing and
|
||||
# importing one. A CI key with no such delegation counts as a non-owner and
|
||||
# cannot push.
|
||||
GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
|
||||
# Comma-separated libp2p multiaddrs.
|
||||
|
||||
Generated
+4
@@ -3473,10 +3473,13 @@ name = "git-remote-gitlawb"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"gitlawb-core",
|
||||
"libc",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3542,6 +3545,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"cid",
|
||||
"clap",
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
@@ -67,8 +67,8 @@ Good today:
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Repository write authorization is secure by default: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. Set `GITLAWB_ENFORCE_OWNER_PUSH=false` only for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint a `did:key` and sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included.
|
||||
- UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
|
||||
- Repository write authorization is one switch: `GITLAWB_ENFORCE_OWNER_PUSH`, which defaults to `true`. With it off — rolling upgrades only — a valid HTTP Signature identifies a pusher but does not authorize one, so every signed caller may push everywhere.
|
||||
- UCAN capabilities are consulted on the push path only. There, the chain's root issuer is anchored to the repository owner, so an owner-rooted, time-bounded delegation that names the repository grants scoped collaborator access for pushing to it, for the `git/push`, `*`, or `repo/admin` actions. A `*` *resource* is refused: a delegation must name the repository it applies to, so it cannot grow to cover repos created later. Push-only cuts the other way too: a delegation grants no read access, so on a private repository the delegate must also be a reader (`gl visibility set`) — the two gates a push crosses are described in [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). The rest is unchanged: there is no revocation path, `nb` constraints are refused rather than interpreted, and no other route — reads, pull requests, issues, agents — consults capabilities at all.
|
||||
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
|
||||
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit `ucan_token`. Slash-form `repo_id` rows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, but `GET /api/v1/tasks/{id}` and GraphQL `task` fail closed for every caller because there is no local repository backing to serve. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
|
||||
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
|
||||
@@ -313,7 +313,7 @@ metadata local disk / optional S3
|
||||
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
|
||||
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
|
||||
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. `gl cert show <repo> <id> --verify` requires `--expect-node <did>`, and the queried node's self-reported DID is **not** accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
|
||||
| UCAN | Delegation token for future capability-based workflows. |
|
||||
| UCAN | Capability token. An owner delegates `git/push` on a repo to another DID; the node honors it when the proof chain roots at that owner. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
|
||||
| Gossipsub | libp2p topic for ref-update events. |
|
||||
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
|
||||
@@ -401,8 +401,8 @@ Important node settings:
|
||||
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
|
||||
| `GITLAWB_P2P_KEY` | Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as `/data/keys/p2p.key` or `./keys/p2p.key`. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, `..` traversal, a trailing `/`, the filesystem root, or a `~/` path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as `p2p_identity_key_load_failed` and sets the `gitlawb_p2p_identity_key_load_failed` metric to 1. Note that `/health` still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created `0600` inside a `0700` directory. A key directory that grants access beyond its owner is tightened to `0700` on start; one that is closed too far is refused with a `chmod 700` remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary `0755` directory is tightened too, but others could not read a `0600` key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (`chmod 600`), and so is one this node cannot read. On other platforms no permissions are enforced. Default `~/.gitlawb/p2p.key`; point it at a persistent volume when running in a container. |
|
||||
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to `false` during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
|
||||
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
|
||||
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
|
||||
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
|
||||
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
|
||||
|
||||
@@ -14,8 +14,17 @@ path = "src/main.rs"
|
||||
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
# Reading the node's DID from `GET /` so a delegated push can address its
|
||||
# invocation to the right executor.
|
||||
serde_json = { workspace = true }
|
||||
# The invocation carries the delegation's expiry, so an unbounded write capability
|
||||
# is never minted; converting the stored i64 timestamp needs chrono.
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# Home-directory lookup for the shared GITLAWB_KEY resolver in gitlawb-core,
|
||||
# which takes `home` as an argument so core's dependency allowlist stays lean.
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,299 @@
|
||||
//! Where the identity key and the delegation store live.
|
||||
//!
|
||||
//! `gl` and `git-remote-gitlawb` have to agree on this. `gl ucan import` writes a
|
||||
//! delegation to `<dir>/delegations/`, and the helper reads it back from the same
|
||||
//! place when it builds the `X-Ucan` header on push. If the two resolve
|
||||
//! `GITLAWB_KEY` differently the push goes out with no header and the node refuses
|
||||
//! the delegate, with nothing on either side to say why — so the rules live here
|
||||
//! once, in the crate both binaries already depend on, rather than being written
|
||||
//! twice and drifting.
|
||||
//!
|
||||
//! The home directory is a parameter rather than something this module looks up.
|
||||
//! `gitlawb-core` is embedded by every consumer and is held to an explicit
|
||||
//! dependency allowlist (`ci/gitlawb-core-allowed-deps.txt`); the callers already
|
||||
//! carry `dirs`, so taking `home` keeps the rules shared without widening core's
|
||||
//! tree. It also makes every case below testable against a fixed home.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Environment variable naming the identity PEM.
|
||||
pub const KEY_ENV: &str = "GITLAWB_KEY";
|
||||
|
||||
/// Name of the PEM file inside the identity directory.
|
||||
pub const KEY_FILE_NAME: &str = "identity.pem";
|
||||
|
||||
/// Directory under the home directory used when `GITLAWB_KEY` is unset.
|
||||
pub const DEFAULT_DIR_NAME: &str = ".gitlawb";
|
||||
|
||||
/// Absolute path of the identity PEM: `$GITLAWB_KEY`, else
|
||||
/// `<home>/.gitlawb/identity.pem`.
|
||||
///
|
||||
/// An empty `GITLAWB_KEY` counts as unset. That is what a shell leaves behind for
|
||||
/// `FOO=` and for an unset variable expanded into a wrapper script, and reading it
|
||||
/// as a path would resolve against the process working directory instead.
|
||||
pub fn identity_key_path(home: Option<&Path>) -> Result<PathBuf> {
|
||||
// `var_os`, not `var`: `var` folds a non-UTF-8 value into the same `Err` as
|
||||
// unset, so an operator whose key path is not valid UTF-8 would silently get
|
||||
// the default directory rather than theirs — or an error naming the real
|
||||
// problem.
|
||||
match std::env::var_os(KEY_ENV) {
|
||||
Some(raw) if !raw.is_empty() => resolve_key_value(Path::new(&raw), home),
|
||||
_ => Ok(require_home(home)?
|
||||
.join(DEFAULT_DIR_NAME)
|
||||
.join(KEY_FILE_NAME)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory holding `identity.pem` and `delegations/` — the parent of
|
||||
/// [`identity_key_path`].
|
||||
pub fn identity_dir(home: Option<&Path>) -> Result<PathBuf> {
|
||||
let key = identity_key_path(home)?;
|
||||
key.parent().map(Path::to_path_buf).ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"{KEY_ENV} has no parent directory: {}",
|
||||
key.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The home directory, demanded only where the value being resolved needs it.
|
||||
///
|
||||
/// An absolute `GITLAWB_KEY` never needs home, so requiring it up front would
|
||||
/// discard a perfectly good key on a host where `dirs::home_dir()` returns `None`
|
||||
/// — no `HOME` and no passwd entry, which is an ordinary container shape. The
|
||||
/// helper would then push unsigned, blaming the home directory for a setting the
|
||||
/// operator had configured correctly.
|
||||
fn require_home(home: Option<&Path>) -> Result<&Path> {
|
||||
home.ok_or_else(|| {
|
||||
Error::Key(format!(
|
||||
"could not determine the home directory, which is needed to resolve this \
|
||||
{KEY_ENV} value. Set {KEY_ENV} to an absolute path to avoid needing it."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the `GITLAWB_KEY` rules to a raw value.
|
||||
///
|
||||
/// Split out from [`identity_key_path`] so the rules can be tested without setting
|
||||
/// a process-global environment variable, which would make the tests race.
|
||||
fn resolve_key_value(raw: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let path = expand_tilde(raw, home)?;
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must be an absolute path (got {}). It also determines where \
|
||||
delegations are stored, and `gl` and `git-remote-gitlawb` do not share a \
|
||||
working directory, so a relative path sends them to different stores.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
if path.parent().is_none() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not the filesystem root (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
raw.display()
|
||||
)));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Expand a leading `~/`, and only that.
|
||||
///
|
||||
/// `~user` is shell syntax this does not implement; leaving its `~` in place makes
|
||||
/// it fail the absolute-path check with a message that names the real problem,
|
||||
/// which beats resolving it somewhere the operator did not ask for. A bare `~` or
|
||||
/// `~/` is refused outright: it names a directory where a file is required, and
|
||||
/// expanding it to the home directory would put the delegation store beside the
|
||||
/// home directory rather than inside it, since the store is the key's *parent*.
|
||||
///
|
||||
/// Matched on the first path component rather than on a string prefix. That is
|
||||
/// what lets the value stay an `OsStr` end to end: the helper's old
|
||||
/// `str::strip_prefix("~/")` needed a `String` first, which is why it reached for
|
||||
/// `env::var` and folded every non-UTF-8 path into "unset".
|
||||
fn expand_tilde(path: &Path, home: Option<&Path>) -> Result<PathBuf> {
|
||||
let mut components = path.components();
|
||||
match components.next() {
|
||||
Some(Component::Normal(first)) if first == OsStr::new("~") => {
|
||||
let rest = components.as_path();
|
||||
if rest.as_os_str().is_empty() {
|
||||
return Err(Error::Key(format!(
|
||||
"{KEY_ENV} must name the key file, not a directory (got {}). \
|
||||
Point it at the PEM, e.g. ~/{DEFAULT_DIR_NAME}/{KEY_FILE_NAME}.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(require_home(home)?.join(rest))
|
||||
}
|
||||
_ => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A home that is absolute on the host running the tests. `/home/op` is not
|
||||
/// absolute on Windows — it has a root but no prefix — so a shared literal
|
||||
/// would make the absolute-path assertions test the wrong thing there.
|
||||
fn home() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\Users\op")
|
||||
} else {
|
||||
PathBuf::from("/home/op")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_value_is_taken_verbatim() {
|
||||
let raw = home().join("data").join("keys").join(KEY_FILE_NAME);
|
||||
assert_eq!(resolve_key_value(&raw, Some(&home())).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_expands_to_the_home_directory() {
|
||||
let resolved = resolve_key_value(Path::new("~/keys/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join("keys").join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// The shell-style spelling of the default resolves to the default. Worth
|
||||
/// pinning: this is the form an operator gets by copying a path out of their
|
||||
/// shell, and the two binaries used to reach it by different routes.
|
||||
#[test]
|
||||
fn the_tilde_spelling_of_the_default_resolves_to_the_default() {
|
||||
let resolved =
|
||||
resolve_key_value(Path::new("~/.gitlawb/identity.pem"), Some(&home())).unwrap();
|
||||
assert_eq!(resolved, home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME));
|
||||
}
|
||||
|
||||
/// A relative value resolves against the working directory, and `gl` and
|
||||
/// `git-remote-gitlawb` do not share one: the import would land where the helper
|
||||
/// never looks.
|
||||
#[test]
|
||||
fn relative_values_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem", "./keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} is relative and must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `~user` is shell syntax, not a path, and a bare `~` names a directory where
|
||||
/// a file is required. Refused rather than guessed at.
|
||||
#[test]
|
||||
fn unsupported_tilde_forms_are_refused() {
|
||||
for raw in ["~", "~/", "~someone/keys/identity.pem"] {
|
||||
assert!(
|
||||
resolve_key_value(Path::new(raw), Some(&home())).is_err(),
|
||||
"{raw} must be refused rather than resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The root has no parent, so the delegation store would have nowhere to go.
|
||||
#[test]
|
||||
fn the_filesystem_root_is_refused() {
|
||||
assert!(resolve_key_value(Path::new("/"), Some(&home())).is_err());
|
||||
}
|
||||
|
||||
/// The whole point of `var_os`: a non-UTF-8 value must reach the rules rather
|
||||
/// than being folded into "unset" by `var`. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_values_reach_the_rules() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let relative = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
assert!(
|
||||
resolve_key_value(Path::new(&relative), Some(&home())).is_err(),
|
||||
"a non-UTF-8 relative path must be refused, not silently defaulted"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = resolve_key_value(Path::new(&absolute), Some(&home())).unwrap();
|
||||
assert_eq!(resolved.as_os_str(), absolute.as_os_str());
|
||||
}
|
||||
|
||||
/// Unset and empty both mean "use the default", and the two accessors must stay
|
||||
/// consistent: the directory is the parent of the key, never a sibling of it.
|
||||
/// The process environment is global, so the two cases share one test and one
|
||||
/// lock rather than racing each other.
|
||||
#[test]
|
||||
fn unset_and_empty_both_select_the_default_directory() {
|
||||
use std::sync::Mutex;
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let restore = std::env::var_os(KEY_ENV);
|
||||
|
||||
std::env::remove_var(KEY_ENV);
|
||||
let unset = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
std::env::set_var(KEY_ENV, "");
|
||||
let empty = (
|
||||
identity_key_path(Some(&home())),
|
||||
identity_dir(Some(&home())),
|
||||
);
|
||||
|
||||
match restore {
|
||||
Some(v) => std::env::set_var(KEY_ENV, v),
|
||||
None => std::env::remove_var(KEY_ENV),
|
||||
}
|
||||
|
||||
for (label, (key, dir)) in [("unset", unset), ("empty", empty)] {
|
||||
assert_eq!(
|
||||
key.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME).join(KEY_FILE_NAME),
|
||||
"{label} key path"
|
||||
);
|
||||
assert_eq!(
|
||||
dir.unwrap(),
|
||||
home().join(DEFAULT_DIR_NAME),
|
||||
"{label} directory"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod no_home_tests {
|
||||
use super::*;
|
||||
|
||||
/// An absolute key needs no home directory. Demanding one up front discarded a
|
||||
/// correctly-configured `GITLAWB_KEY` on any host where `dirs::home_dir()`
|
||||
/// returns `None` — no `HOME` and no passwd entry, an ordinary container shape —
|
||||
/// and the helper then pushed unsigned while blaming the home directory.
|
||||
#[test]
|
||||
fn an_absolute_key_resolves_without_a_home_directory() {
|
||||
let raw = if cfg!(windows) {
|
||||
r"C:\data\keys\identity.pem"
|
||||
} else {
|
||||
"/data/keys/identity.pem"
|
||||
};
|
||||
let resolved = resolve_key_value(Path::new(raw), None)
|
||||
.expect("an absolute key must not need a home directory");
|
||||
assert_eq!(resolved, PathBuf::from(raw));
|
||||
assert_eq!(resolved.parent().unwrap(), Path::new(raw).parent().unwrap());
|
||||
}
|
||||
|
||||
/// The forms that genuinely need a home still say so, rather than resolving
|
||||
/// somewhere arbitrary.
|
||||
#[test]
|
||||
fn the_forms_that_need_a_home_report_its_absence() {
|
||||
let err = resolve_key_value(Path::new("~/keys/identity.pem"), None)
|
||||
.expect_err("a ~/ path cannot resolve without a home directory");
|
||||
assert!(
|
||||
err.to_string().contains("home directory"),
|
||||
"the error must name the missing home directory, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod encrypt;
|
||||
pub mod error;
|
||||
pub mod http_sig;
|
||||
pub mod identity;
|
||||
pub mod identity_path;
|
||||
// `url` is the one dependency here that drags a tail (idna, then the icu
|
||||
// crates), and gitlawb-core is allowlisted to stay embeddable. Every client that
|
||||
// needs this predicate already parses URLs, so they opt in and nothing else
|
||||
|
||||
@@ -30,10 +30,30 @@ pub fn is_bidi_format(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Both halves of INV-6 in one call: drop every `Cc` control (which defangs
|
||||
/// ANSI and OSC escapes) and every bidi/format control that
|
||||
/// [`is_bidi_format`] names. What remains is safe to write to a terminal.
|
||||
///
|
||||
/// Length is not this function's business: a caller that shows the text
|
||||
/// decides how much of it to show.
|
||||
pub fn strip_terminal_controls(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !is_bidi_format(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_removes_escapes_and_reordering_but_keeps_text() {
|
||||
let hostile = "ok\u{1b}[31m red\u{1b}]0;title\u{7}\u{202E}dlrow\u{202C} \u{0627}\u{200D}";
|
||||
let clean = strip_terminal_controls(hostile);
|
||||
assert_eq!(clean, "ok[31m red]0;titledlrow \u{0627}\u{200D}");
|
||||
assert!(!clean.chars().any(|c| c.is_control() || is_bidi_format(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_every_reordering_code_point() {
|
||||
// Asserted individually so a partial predicate (e.g. one that covers the
|
||||
|
||||
+785
-29
@@ -16,6 +16,19 @@ use crate::did::Did;
|
||||
use crate::identity::Keypair;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Proof chains deeper than this fail closed, in every walk over `prf`:
|
||||
/// [`Ucan::verify_chain`], [`Ucan::chain_lifetime_is_bounded`] and
|
||||
/// [`Ucan::chain_grants_push_to`] all stop here. Nothing this codebase mints is
|
||||
/// longer than owner → agent → node. The bound exists for the hand-built token:
|
||||
/// the node verifies the chain on the `X-Ucan` header of any signed request, and
|
||||
/// nothing else says how deep a sender may nest `prf`. In practice not very
|
||||
/// deep — a proof is a JSON string inside the next link's JSON, so each level
|
||||
/// re-escapes the one beneath it and the encoding roughly doubles per link
|
||||
/// (eight links is about 11 KiB, thirteen about 270 KiB) — but a walk should
|
||||
/// refuse on its own terms rather than count on the encoding running the sender
|
||||
/// out of room first.
|
||||
pub const MAX_CHAIN_DEPTH: usize = 8;
|
||||
|
||||
/// A UCAN capability: what resource the token grants access to.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
@@ -49,11 +62,32 @@ impl Capability {
|
||||
/// action field and `repo/admin` in the parent's action position act as
|
||||
/// wildcards that cover any delegated value; wildcards on `self` carry no
|
||||
/// special meaning.
|
||||
///
|
||||
/// Constraints (`nb`) participate, and conservatively:
|
||||
///
|
||||
/// | parent | child | verdict |
|
||||
/// |---|---|---|
|
||||
/// | none | anything | attenuated — adding constraints narrows |
|
||||
/// | some | identical | attenuated |
|
||||
/// | some | different | refused — narrowing is unprovable without semantics |
|
||||
/// | some | none | refused — dropping constraints widens |
|
||||
///
|
||||
/// The last row is the one that matters. Ignoring `nb` here let a holder of a
|
||||
/// constrained capability re-delegate the same resource and action with the
|
||||
/// constraints removed, and the chain still verified — so a consumer that
|
||||
/// refuses constrained capabilities at the leaf saw an unconstrained one and
|
||||
/// granted it. Since `nb` has no interpreted semantics yet, "different" cannot
|
||||
/// be shown to be narrower and is refused with it.
|
||||
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
|
||||
let resource_ok = parent.with == self.with || parent.with == "*";
|
||||
let action_ok =
|
||||
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
|
||||
resource_ok && action_ok
|
||||
let constraints_ok = match (&parent.constraints, &self.constraints) {
|
||||
(None, _) => true,
|
||||
(Some(p), Some(c)) => p == c,
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
resource_ok && action_ok && constraints_ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +105,157 @@ pub mod caps {
|
||||
pub const REPO_ADMIN: &str = "repo/admin";
|
||||
}
|
||||
|
||||
/// Rules shared by every boundary that decides whether a UCAN can push.
|
||||
///
|
||||
/// Four places answer "can this token push to this repository?": `gl ucan
|
||||
/// delegate` and the MCP `ucan_delegate` tool when a token is issued, `gl ucan
|
||||
/// import` when one is stored, `git-remote-gitlawb` when it mints an invocation,
|
||||
/// and the node when it authorizes the push. For three review rounds each of them
|
||||
/// carried its own copy of the answer, and every round found a token that one
|
||||
/// boundary accepted and the next refused: a constrained grant that imported and
|
||||
/// then authorized nothing; a wildcard proof the helper narrowed and the node
|
||||
/// rejected; a resource whose owner the leaf named but the chain root did not.
|
||||
///
|
||||
/// The rules live here, once, in the crate all four already depend on. A boundary
|
||||
/// may add checks of its own — the node anchors the chain root to a repository
|
||||
/// record it holds independently, import binds the audience to the local key —
|
||||
/// but the definition of a *usable push capability* is not one of them.
|
||||
pub mod push {
|
||||
use super::{caps, Capability, Ucan};
|
||||
|
||||
/// The `did:key` representation rule: `did:key:z6Mk…` and bare `z6Mk…` are
|
||||
/// the same identity. Mirror rows store the bare form; canonical rows and
|
||||
/// every token store the full form.
|
||||
///
|
||||
/// Collapses representation only within `did:key`. `did:web` and
|
||||
/// `did:gitlawb` share the base58 space, so a trailing-segment compare would
|
||||
/// treat `did:key:X` and `did:gitlawb:X` as equal; after stripping the prefix,
|
||||
/// a value that still contains `:` is a non-key DID and matches nothing bare.
|
||||
pub fn did_key_eq(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// The actions that authorize a push: `git/push` itself, the action wildcard,
|
||||
/// and `repo/admin`, which covers it.
|
||||
pub fn is_push_action(can: &str) -> bool {
|
||||
can == caps::GIT_PUSH || can == "*" || can == caps::REPO_ADMIN
|
||||
}
|
||||
|
||||
/// `gitlawb://repos/<owner>/<repo>` → `(owner, repo)`, or `None` for any other
|
||||
/// shape. Exactly two non-empty segments: an owner DID never contains `/`, so
|
||||
/// a third segment, a trailing slash, or an empty half is malformed rather than
|
||||
/// something to interpret.
|
||||
pub fn parse_repo_resource(with: &str) -> Option<(&str, &str)> {
|
||||
let rest = with.strip_prefix("gitlawb://repos/")?;
|
||||
let (owner, repo) = rest.split_once('/')?;
|
||||
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some((owner, repo))
|
||||
}
|
||||
|
||||
/// The canonical repository name a capability or URL names: at most one
|
||||
/// trailing `.git` stripped, the same normalization the node's smart-HTTP
|
||||
/// routes apply (`smart_http_repo_name`). A git remote carries the suffix;
|
||||
/// `RepoRecord.name` does not, so every boundary that compares a capability
|
||||
/// to a record must go through the same strip or a `…/myrepo.git` capability
|
||||
/// names a repository that never exists.
|
||||
///
|
||||
/// At-most-once, not repeatedly: a repository literally named `foo.git`
|
||||
/// (creatable via the peer mirror path, which skips API name validation) is
|
||||
/// addressed as `foo.git.git`, and stripping twice would send it to `foo`.
|
||||
pub fn canonical_repo_name(repo: &str) -> &str {
|
||||
repo.strip_suffix(".git").unwrap_or(repo)
|
||||
}
|
||||
|
||||
/// A push-class capability whose resource is the wildcard. Refused at
|
||||
/// issuance, at import, by the helper, and by the node: a delegation's scope is
|
||||
/// fixed when it is issued, and `*` cannot say which repositories it covered
|
||||
/// at that moment.
|
||||
pub fn is_push_wildcard(with: &str, can: &str) -> bool {
|
||||
with == "*" && is_push_action(can)
|
||||
}
|
||||
|
||||
pub use super::MAX_CHAIN_DEPTH;
|
||||
|
||||
impl Capability {
|
||||
/// Push-class, unconstrained, and naming exactly this repository.
|
||||
///
|
||||
/// Unconstrained because `nb` has no semantics yet: an owner who wrote
|
||||
/// constraints meant to restrict, and honouring the capability while
|
||||
/// ignoring them would grant more than was intended. The owner segment is
|
||||
/// compared with [`did_key_eq`], so a token issued against the full DID
|
||||
/// matches a mirror row keyed on the bare form.
|
||||
///
|
||||
/// The capability's repo segment goes through [`canonical_repo_name`]
|
||||
/// while `repo` (a `RepoRecord.name`, already canonical) does not: a
|
||||
/// delegation written `…/myrepo.git` authorizes `myrepo`, the same
|
||||
/// mapping the smart-HTTP URL applies.
|
||||
pub fn grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.constraints.is_none()
|
||||
&& is_push_action(&self.can)
|
||||
&& parse_repo_resource(&self.with)
|
||||
.is_some_and(|(o, r)| did_key_eq(o, owner) && canonical_repo_name(r) == repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ucan {
|
||||
/// Whether this chain — the leaf **and every proof behind it** — carries a
|
||||
/// capability that [`Capability::grants_push_to`] this repository.
|
||||
///
|
||||
/// Every link, not just the leaf. [`Capability::is_attenuated_by`] accepts
|
||||
/// a concrete child under a `*` parent, so a leaf that names the repository
|
||||
/// can sit on a proof that names every repository the root owns — including
|
||||
/// ones created after the delegation was issued. Checking the leaf alone
|
||||
/// let exactly that through.
|
||||
///
|
||||
/// This establishes scope, not trust. It does not verify signatures,
|
||||
/// expiry, or audience; call [`Ucan::verify_chain`] first, and anchor the
|
||||
/// root it returns to something held independently of the token.
|
||||
///
|
||||
/// Fails closed on anything it cannot vouch for: a proof that does not
|
||||
/// decode, more than one proof per link, or a chain deeper than
|
||||
/// [`MAX_CHAIN_DEPTH`].
|
||||
pub fn chain_grants_push_to(&self, owner: &str, repo: &str) -> bool {
|
||||
self.chain_grants_push_to_at(owner, repo, 0)
|
||||
}
|
||||
|
||||
fn chain_grants_push_to_at(&self, owner: &str, repo: &str, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.any(|c| c.grants_push_to(owner, repo))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// `verify_chain` refuses more than one proof per link for the same
|
||||
// reason: two proofs mean two roots, and nothing says which authorized
|
||||
// what.
|
||||
if self.payload.prf.len() > 1 {
|
||||
return false;
|
||||
}
|
||||
match self.payload.prf.first() {
|
||||
None => true,
|
||||
Some(token) => match Ucan::decode(token) {
|
||||
Ok(proof) => proof.chain_grants_push_to_at(owner, repo, depth + 1),
|
||||
Err(_) => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The UCAN payload (what gets signed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UcanPayload {
|
||||
@@ -146,6 +331,37 @@ impl Ucan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every link in this chain carries a finite `exp`.
|
||||
///
|
||||
/// `exp` is optional in the format, and [`Self::is_expired`] reports `false`
|
||||
/// when it is absent — so a link without one never expires. With no revocation
|
||||
/// mechanism, a chain containing such a link is a permanent grant: a leaked
|
||||
/// token cannot be withdrawn, and the issuer's only remedy is to rotate the
|
||||
/// identity the resource is keyed on.
|
||||
///
|
||||
/// Consumers that turn a UCAN into write authority should require this. It is
|
||||
/// deliberately not enforced inside [`Self::verify_chain`], because a
|
||||
/// non-expiring token is well-formed and may be perfectly appropriate for a
|
||||
/// read-only or advisory capability; whether an unbounded grant is acceptable
|
||||
/// is the consumer's policy, not the format's.
|
||||
///
|
||||
/// Fails closed past [`MAX_CHAIN_DEPTH`], like every other walk over `prf`.
|
||||
pub fn chain_lifetime_is_bounded(&self) -> bool {
|
||||
self.chain_lifetime_is_bounded_at(0)
|
||||
}
|
||||
|
||||
fn chain_lifetime_is_bounded_at(&self, depth: usize) -> bool {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if self.payload.exp.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.payload.prf.iter().all(|token| {
|
||||
Self::decode(token).is_ok_and(|proof| proof.chain_lifetime_is_bounded_at(depth + 1))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if this UCAN's not-before time is in the future (token not yet valid).
|
||||
pub fn is_before_valid(&self) -> bool {
|
||||
if let Some(nbf) = self.payload.nbf {
|
||||
@@ -248,8 +464,33 @@ impl Ucan {
|
||||
/// 3. Check the proof is not expired
|
||||
/// 4. Recursively verify the proof's own chain
|
||||
///
|
||||
/// A UCAN with no proofs (root capability) passes trivially.
|
||||
pub fn verify_chain(&self) -> Result<()> {
|
||||
/// A UCAN with no proofs is its own root, so it returns its own issuer.
|
||||
///
|
||||
/// **This establishes internal consistency, not trust.** `did:key` is
|
||||
/// self-certifying, so anyone can mint a keypair and produce a chain that
|
||||
/// verifies. A caller making an authorization decision MUST compare the
|
||||
/// returned root against an identity it trusts for some reason outside this
|
||||
/// token — a repo owner, a configured value, a registry lookup. Discarding
|
||||
/// the return value is only correct when the caller is checking that a token
|
||||
/// is well-formed and deliberately does not care who issued it.
|
||||
///
|
||||
/// The walk stops at [`MAX_CHAIN_DEPTH`] and fails closed there. The node
|
||||
/// runs this on the `X-Ucan` header of any signed request before it asks
|
||||
/// what the chain grants, so this is the first place sender-chosen depth is
|
||||
/// felt: each link costs a signature check and a decode of everything
|
||||
/// beneath it. The scope walk was bounded and this one was not, which made
|
||||
/// that bound decorative — a deep chain met the unbounded walk first.
|
||||
pub fn verify_chain(&self) -> Result<Did> {
|
||||
self.verify_chain_at(0)
|
||||
}
|
||||
|
||||
fn verify_chain_at(&self, depth: usize) -> Result<Did> {
|
||||
if depth >= MAX_CHAIN_DEPTH {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain deeper than {MAX_CHAIN_DEPTH} links is not accepted"
|
||||
)));
|
||||
}
|
||||
|
||||
// First verify our own signature
|
||||
self.verify_signature()?;
|
||||
|
||||
@@ -261,34 +502,44 @@ impl Ucan {
|
||||
return Err(Error::Ucan("token is not yet valid".to_string()));
|
||||
}
|
||||
|
||||
for proof_token in &self.payload.prf {
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// Every delegated capability must be covered by the proof (attenuation).
|
||||
for cap in &self.payload.att {
|
||||
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
|
||||
if !covered {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the proof's signature and chain recursively
|
||||
proof.verify_chain()?;
|
||||
if self.payload.prf.len() > 1 {
|
||||
return Err(Error::Ucan(
|
||||
"multi-proof chains are not supported: more than one proof means \
|
||||
more than one root, and which root authorized a given capability \
|
||||
is ambiguous"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let Some(proof_token) = self.payload.prf.first() else {
|
||||
// No proofs: this token is its own root.
|
||||
return Ok(self.payload.iss.clone());
|
||||
};
|
||||
|
||||
let proof = Self::decode(proof_token)
|
||||
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
|
||||
|
||||
// The proof's audience must be this UCAN's issuer
|
||||
if proof.payload.aud != self.payload.iss {
|
||||
return Err(Error::Ucan(format!(
|
||||
"proof chain broken: proof audience {} does not match issuer {}",
|
||||
proof.payload.aud, self.payload.iss
|
||||
)));
|
||||
}
|
||||
|
||||
// Every delegated capability must be covered by the proof (attenuation).
|
||||
for cap in &self.payload.att {
|
||||
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
|
||||
if !covered {
|
||||
return Err(Error::Ucan(format!(
|
||||
"capability attenuation violated: '{}' on '{}' not covered by proof",
|
||||
cap.can, cap.with
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse; the root of the proof's chain is the root of ours.
|
||||
proof.verify_chain_at(depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,4 +915,509 @@ mod tests {
|
||||
|
||||
delegated.verify_chain().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_the_root_issuer_of_a_delegated_chain() {
|
||||
// owner -> agent (delegation), agent -> node (invocation).
|
||||
// The root is the owner: that is the identity the whole chain rests on,
|
||||
// and the only one a caller can meaningfully anchor a trust decision to.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("chain must verify"),
|
||||
owner.did(),
|
||||
"the root issuer is the owner who started the chain, not the agent presenting it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A chain is only bounded if EVERY link is. An unbounded link anywhere makes
|
||||
/// the whole grant permanent, because `is_expired` reports false for it and
|
||||
/// there is no revocation path to withdraw it.
|
||||
#[test]
|
||||
fn chain_lifetime_is_bounded_requires_an_expiry_on_every_link() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
let hour = Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let bounded_root = Ucan::issue(&owner, agent.did(), cap(), Some(hour)).expect("issue");
|
||||
let unbounded_root = Ucan::issue(&owner, agent.did(), cap(), None).expect("issue");
|
||||
|
||||
assert!(
|
||||
Ucan::delegate(&agent, node.did(), cap(), Some(hour), &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"both links finite"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), None, &bounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"the leaf has no expiry, so the grant never lapses"
|
||||
);
|
||||
assert!(
|
||||
!Ucan::delegate(&agent, node.did(), cap(), Some(hour), &unbounded_root)
|
||||
.expect("wrap")
|
||||
.chain_lifetime_is_bounded(),
|
||||
"a bounded leaf cannot rescue an unbounded proof: the holder can always \
|
||||
mint a fresh leaf from it"
|
||||
);
|
||||
assert!(
|
||||
!unbounded_root.chain_lifetime_is_bounded(),
|
||||
"a self-issued token with no expiry is itself unbounded"
|
||||
);
|
||||
}
|
||||
|
||||
/// A three-link chain: owner -> lead -> agent, which is the real shape of an
|
||||
/// org delegating to a team lead who delegates to a CI identity.
|
||||
///
|
||||
/// Every other chain here is depth two, where the immediate proof IS the root —
|
||||
/// so nothing distinguishes recursing to the true root from simply returning the
|
||||
/// proof's issuer. Both `assert_eq!` and `assert_ne!` below are load-bearing:
|
||||
/// without the second, returning the middle issuer would still satisfy a test
|
||||
/// that only checked "not the leaf".
|
||||
#[test]
|
||||
fn verify_chain_walks_past_the_immediate_proof_to_the_true_root() {
|
||||
let owner = Keypair::generate();
|
||||
let lead = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let cap = || vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let root = Ucan::issue(&owner, lead.did(), cap(), None).expect("owner -> lead");
|
||||
let mid = Ucan::delegate(&lead, agent.did(), cap(), None, &root).expect("lead -> agent");
|
||||
let leaf = Ucan::delegate(&agent, node.did(), cap(), None, &mid).expect("agent -> node");
|
||||
|
||||
let found = leaf.verify_chain().expect("a three-link chain must verify");
|
||||
assert_eq!(
|
||||
found,
|
||||
owner.did(),
|
||||
"the root is the owner who started the chain, two hops up"
|
||||
);
|
||||
assert_ne!(
|
||||
found,
|
||||
lead.did(),
|
||||
"returning the immediate proof's issuer is not walking to the root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_returns_self_as_root_for_a_self_issued_token() {
|
||||
// A token with no proofs roots at its own issuer. This is what makes a
|
||||
// self-minted token useless: the caller compares this against the repo
|
||||
// owner and it will only ever match when the presenter IS the owner.
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan =
|
||||
Ucan::issue(&agent, node.did(), vec![Capability::new("*", "*")], None).expect("issue");
|
||||
|
||||
assert_eq!(
|
||||
ucan.verify_chain().expect("a root token still verifies"),
|
||||
agent.did(),
|
||||
"a self-minted token roots at the minter, however permissive its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stripping `nb` is a widening, and a widening must fail attenuation.
|
||||
///
|
||||
/// Without this, a constrained delegation is trivially escalated: the holder
|
||||
/// re-delegates the same resource and action with the constraints removed,
|
||||
/// `verify_chain` accepts the chain because attenuation only compared `with`
|
||||
/// and `can`, and a consumer that refuses constrained capabilities at the leaf
|
||||
/// (as the node's push gate does) then sees an unconstrained one and grants it.
|
||||
/// Guarding only the leaf guards the wrong end of the chain.
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_child_that_strips_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }));
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![constrained], None)
|
||||
.expect("issue constrained delegation");
|
||||
|
||||
// Same resource, same action, constraints dropped.
|
||||
let widened = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let forged =
|
||||
Ucan::delegate(&agent, node.did(), vec![widened], None, &delegation).expect("wrap");
|
||||
|
||||
let err = forged
|
||||
.verify_chain()
|
||||
.expect_err("dropping the parent's constraints must fail attenuation");
|
||||
assert!(
|
||||
err.to_string().contains("attenuation"),
|
||||
"the failure must name attenuation, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_accepts_a_child_that_keeps_the_parents_constraints() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let nb = serde_json::json!({ "refs": ["refs/heads/feat/*"] });
|
||||
let constrained = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(nb.clone());
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), vec![constrained.clone()], None).expect("issue");
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![constrained], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation
|
||||
.verify_chain()
|
||||
.expect("an unchanged constraint must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconstrained_parent_still_allows_a_child_to_add_constraints() {
|
||||
// Adding `nb` narrows, which is always a legal attenuation.
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
|
||||
let open = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH);
|
||||
let delegation = Ucan::issue(&owner, agent.did(), vec![open], None).expect("issue");
|
||||
let narrowed = Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/main"] }));
|
||||
let invocation =
|
||||
Ucan::delegate(&agent, node.did(), vec![narrowed], None, &delegation).expect("wrap");
|
||||
|
||||
assert_eq!(
|
||||
invocation.verify_chain().expect("narrowing must verify"),
|
||||
owner.did()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_chain_rejects_a_multi_proof_chain() {
|
||||
// Two proofs mean two roots, and nothing says which root authorized a
|
||||
// given capability. Returning either one would be unsound, so refuse.
|
||||
let owner_a = Keypair::generate();
|
||||
let owner_b = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let proof_a = Ucan::issue(&owner_a, agent.did(), caps_vec.clone(), None).expect("issue a");
|
||||
let proof_b = Ucan::issue(&owner_b, agent.did(), caps_vec.clone(), None).expect("issue b");
|
||||
|
||||
// `delegate` only ever writes one proof, so build the two-proof payload by hand.
|
||||
let payload = UcanPayload {
|
||||
ucan: "1.0.0".to_string(),
|
||||
iss: agent.did(),
|
||||
aud: node.did(),
|
||||
att: caps_vec,
|
||||
exp: None,
|
||||
nbf: None,
|
||||
prf: vec![
|
||||
proof_a.encode().expect("encode a"),
|
||||
proof_b.encode().expect("encode b"),
|
||||
],
|
||||
};
|
||||
let signing_bytes = serde_json::to_vec(&payload).expect("serialize payload");
|
||||
let s = agent.sign_b64(&signing_bytes);
|
||||
let multi = Ucan { payload, s };
|
||||
|
||||
let err = multi
|
||||
.verify_chain()
|
||||
.expect_err("a two-proof chain must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("multi-proof"),
|
||||
"the error must name the reason, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod push_scope_tests {
|
||||
use super::push::*;
|
||||
use super::{caps, Capability, Ucan};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_key_eq_collapses_representation_only_within_did_key() {
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "z6MkAbc"));
|
||||
assert!(did_key_eq("z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(did_key_eq("did:key:z6MkAbc", "did:key:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:key:z6MkXyz"));
|
||||
// A bare id must never match across methods: `did:gitlawb` shares the
|
||||
// base58 space with `did:key`.
|
||||
assert!(!did_key_eq("did:gitlawb:z6MkAbc", "z6MkAbc"));
|
||||
assert!(!did_key_eq("did:key:z6MkAbc", "did:gitlawb:z6MkAbc"));
|
||||
assert!(!did_key_eq("did:web:example.com", "example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_repo_resource_requires_exactly_two_segments() {
|
||||
assert_eq!(
|
||||
parse_repo_resource("gitlawb://repos/did:key:z6Mk/r"),
|
||||
Some(("did:key:z6Mk", "r"))
|
||||
);
|
||||
for bad in [
|
||||
"*",
|
||||
"",
|
||||
"gitlawb://repos/",
|
||||
"gitlawb://repos/owner",
|
||||
"gitlawb://repos/owner/",
|
||||
"gitlawb://repos//r",
|
||||
"gitlawb://repos/owner/r/extra",
|
||||
"https://repos/owner/r",
|
||||
] {
|
||||
assert_eq!(parse_repo_resource(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_push_wildcard_is_the_resource_wildcard_with_a_push_action() {
|
||||
for can in [caps::GIT_PUSH, "*", caps::REPO_ADMIN] {
|
||||
assert!(is_push_wildcard("*", can), "{can}");
|
||||
}
|
||||
assert!(!is_push_wildcard("*", caps::GIT_FETCH));
|
||||
assert!(!is_push_wildcard("*", caps::PR_OPEN));
|
||||
assert!(!is_push_wildcard("gitlawb://repos/o/r", caps::GIT_PUSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_grants_push_only_when_concrete_unconstrained_and_push_class() {
|
||||
let owner = "did:key:z6MkOwner";
|
||||
let ok = Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH);
|
||||
assert!(ok.grants_push_to(owner, "r"));
|
||||
// Bare and full owner forms are the same identity.
|
||||
assert!(ok.grants_push_to("z6MkOwner", "r"));
|
||||
|
||||
assert!(!Capability::new("*", caps::GIT_PUSH).grants_push_to(owner, "r"));
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_FETCH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/other", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOther/r", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/r", caps::GIT_PUSH)
|
||||
.with_constraints(serde_json::json!({"max_bytes": 1}))
|
||||
.grants_push_to(owner, "r")
|
||||
);
|
||||
for can in ["*", caps::REPO_ADMIN] {
|
||||
assert!(
|
||||
Capability::new("gitlawb://repos/did:key:z6MkOwner/r", can)
|
||||
.grants_push_to(owner, "r"),
|
||||
"{can} covers git/push"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_git_suffixed_capability_names_the_canonical_repo() {
|
||||
let owner = "did:key:z6MkOwner";
|
||||
// A git remote carries `.git`; the record does not. The capability
|
||||
// matches the repository the URL would reach, so a delegation pasted
|
||||
// from the remote still authorizes.
|
||||
assert!(Capability::new(
|
||||
"gitlawb://repos/did:key:z6MkOwner/myrepo.git",
|
||||
caps::GIT_PUSH
|
||||
)
|
||||
.grants_push_to(owner, "myrepo"));
|
||||
// At most one suffix is stripped: a repo literally named `foo.git`
|
||||
// (reachable as `foo.git.git` over smart HTTP) is a different
|
||||
// repository from `foo`, and a single `.git` must not resolve to it.
|
||||
assert!(Capability::new(
|
||||
"gitlawb://repos/did:key:z6MkOwner/foo.git.git",
|
||||
caps::GIT_PUSH
|
||||
)
|
||||
.grants_push_to(owner, "foo.git"));
|
||||
assert!(
|
||||
!Capability::new("gitlawb://repos/did:key:z6MkOwner/foo.git", caps::GIT_PUSH)
|
||||
.grants_push_to(owner, "foo.git")
|
||||
);
|
||||
}
|
||||
|
||||
/// The chain walk, at every depth: a wildcard anywhere denies; concrete
|
||||
/// everywhere grants. The grandparent case is the one a single-level check
|
||||
/// misses, because the immediate proof already names the repo.
|
||||
#[test]
|
||||
fn chain_grants_push_only_when_every_link_names_the_repo() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let b = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let concrete = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
let wild = || vec![Capability::new("*", caps::GIT_PUSH)];
|
||||
|
||||
// Single self-issued link.
|
||||
assert!(Ucan::issue(&owner, a.did(), concrete(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
assert!(!Ucan::issue(&owner, a.did(), wild(), Some(hour()))
|
||||
.unwrap()
|
||||
.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Two links: concrete leaf on a wildcard proof must deny.
|
||||
let wild_proof = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let leaf = Ucan::delegate(&a, node.did(), concrete(), Some(hour()), &wild_proof).unwrap();
|
||||
assert!(
|
||||
leaf.verify_chain().is_ok(),
|
||||
"attenuation accepts it — that is why we walk"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Three links: wildcard grandparent, concrete parent, concrete leaf.
|
||||
let gp = Ucan::issue(&owner, a.did(), wild(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.verify_chain().is_ok());
|
||||
assert!(
|
||||
!leaf.chain_grants_push_to(&owner_s, "r"),
|
||||
"a wildcard two links up must deny"
|
||||
);
|
||||
|
||||
// Three links, all concrete.
|
||||
let gp = Ucan::issue(&owner, a.did(), concrete(), Some(hour())).unwrap();
|
||||
let parent = Ucan::delegate(&a, b.did(), concrete(), Some(hour()), &gp).unwrap();
|
||||
let leaf = Ucan::delegate(&b, node.did(), concrete(), Some(hour()), &parent).unwrap();
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
// And the bare owner form is the same repo.
|
||||
assert!(leaf.chain_grants_push_to(owner_s.strip_prefix("did:key:").unwrap(), "r"));
|
||||
// But not another repo.
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "other"));
|
||||
}
|
||||
|
||||
/// Fail closed on anything the walk cannot vouch for.
|
||||
#[test]
|
||||
fn chain_walk_fails_closed_on_undecodable_or_multi_proofs_or_excess_depth() {
|
||||
let owner = Keypair::generate();
|
||||
let a = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut broken = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
broken.payload.prf = vec!["not a ucan".into()];
|
||||
assert!(!broken.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
let good = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut two = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
two.payload.prf = vec![good.encode().unwrap(), good.encode().unwrap()];
|
||||
assert!(!two.chain_grants_push_to(&owner_s, "r"));
|
||||
|
||||
// Deeper than MAX_CHAIN_DEPTH, all concrete: refused on depth alone.
|
||||
let mut cur = Ucan::issue(&owner, a.did(), cap(), Some(hour())).unwrap();
|
||||
let mut signer = a;
|
||||
for _ in 0..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
assert!(
|
||||
!cur.chain_grants_push_to(&owner_s, "r"),
|
||||
"depth bound must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every walk over `prf` stops at the same depth. The round-ten P1: the scope
|
||||
/// walk was bounded while `verify_chain` and `chain_lifetime_is_bounded` were
|
||||
/// not, and the node runs `verify_chain` on an attacker-supplied `X-Ucan` header
|
||||
/// before the bounded walk ever sees it, so the bound protected nothing.
|
||||
#[cfg(test)]
|
||||
mod chain_depth_tests {
|
||||
use super::{caps, Capability, Ucan, MAX_CHAIN_DEPTH};
|
||||
use crate::identity::Keypair;
|
||||
|
||||
fn hour() -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::Utc::now() + chrono::Duration::hours(1)
|
||||
}
|
||||
|
||||
/// A well-formed, fully signed, expiring chain of exactly `links` links, every
|
||||
/// one naming the owner's repository, so the only thing any walk can object
|
||||
/// to is how long it is. Returns the leaf and the owner it roots at.
|
||||
fn concrete_chain(links: usize) -> (Ucan, Keypair) {
|
||||
assert!(links >= 1);
|
||||
let owner = Keypair::generate();
|
||||
let owner_s = owner.did().to_string();
|
||||
let res = format!("gitlawb://repos/{owner_s}/r");
|
||||
let cap = || vec![Capability::new(&res, caps::GIT_PUSH)];
|
||||
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour())).unwrap();
|
||||
for _ in 1..links {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour()), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
(cur, owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_at_the_bound_passes_every_walk() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH);
|
||||
let owner_s = owner.did().to_string();
|
||||
assert_eq!(
|
||||
leaf.verify_chain()
|
||||
.expect("a chain at the bound must verify"),
|
||||
owner.did()
|
||||
);
|
||||
assert!(leaf.chain_lifetime_is_bounded());
|
||||
assert!(leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_link_past_the_bound_fails_every_walk_closed() {
|
||||
let (leaf, owner) = concrete_chain(MAX_CHAIN_DEPTH + 1);
|
||||
let owner_s = owner.did().to_string();
|
||||
|
||||
let err = leaf
|
||||
.verify_chain()
|
||||
.expect_err("verify_chain must refuse a chain past the bound");
|
||||
assert!(
|
||||
err.to_string().contains("deeper than"),
|
||||
"the refusal must say depth was the reason, not a fake signature failure: {err}"
|
||||
);
|
||||
assert!(
|
||||
!leaf.chain_lifetime_is_bounded(),
|
||||
"an unwalkable chain cannot be vouched for as bounded"
|
||||
);
|
||||
assert!(!leaf.chain_grants_push_to(&owner_s, "r"));
|
||||
}
|
||||
|
||||
/// A chain well past the bound is refused from the top, before any per-link
|
||||
/// work. Only a few links past it, deliberately: a proof is embedded as a JSON
|
||||
/// string inside the next link's JSON, so every level re-escapes the one
|
||||
/// inside it and the encoding roughly doubles per link — eight links is about
|
||||
/// 10 KiB, sixteen is megabytes, and a chain of "a few hundred levels" cannot
|
||||
/// physically be encoded, let alone sent as a header. The bound is still
|
||||
/// what stops the reachable dozen-odd levels from each costing a decode of
|
||||
/// everything beneath them.
|
||||
#[test]
|
||||
fn a_chain_well_past_the_bound_is_refused_not_walked() {
|
||||
let (leaf, _) = concrete_chain(MAX_CHAIN_DEPTH + 4);
|
||||
let err = leaf.verify_chain().expect_err("must refuse");
|
||||
assert!(err.to_string().contains("deeper than"), "{err}");
|
||||
assert!(!leaf.chain_lifetime_is_bounded());
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ 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"
|
||||
|
||||
@@ -6,29 +6,24 @@ use axum::Json;
|
||||
use crate::auth::AuthenticatedDid;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::visibility::{visibility_check, Decision};
|
||||
|
||||
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs
|
||||
/// Returns [{oid, cid}] for every encrypted blob in the repo, to any caller who
|
||||
/// can read the repo. Not recipient-scoped: recipient identities are not stored,
|
||||
/// so access control here is repo readability and decryption is gated by the
|
||||
/// envelope crypto (only a real recipient can open an envelope).
|
||||
///
|
||||
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
|
||||
/// same as issues/changelogs — so a public-but-quarantined mirror cannot leak
|
||||
/// its encrypted blob index.
|
||||
pub async fn list_encrypted_blobs(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
@@ -45,17 +40,9 @@ pub async fn get_encrypted_blob(
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo, oid)): Path<(String, String, String)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let cid = state
|
||||
.db
|
||||
.encrypted_blob_cid(&record.id, &oid)
|
||||
@@ -81,17 +68,9 @@ pub async fn replicate_encrypted_blobs(
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(&rules, record.is_public, &record.owner_did, caller, "/") == Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
let rows = state.db.list_all_encrypted_blobs(&record.id).await?;
|
||||
let blobs: Vec<_> = rows
|
||||
.into_iter()
|
||||
|
||||
@@ -238,49 +238,32 @@ pub async fn close_issue(
|
||||
headers: axum::http::HeaderMap,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
// READ-GATE before any snapshot work or rate charging. The author fallback below
|
||||
// needs the issue blob, which needs the repo tree, so authorship cannot be established
|
||||
// without a download; but a caller who cannot even READ the repo must be
|
||||
// stopped here, cheaply, before any Tigris transfer or extraction happens.
|
||||
// Without this, any signed non-owner could issue parallel close requests for
|
||||
// arbitrary issue ids and drive unbounded downloads and blocking extraction
|
||||
// (a disposable-identity DoS), because the route has no other pre-authorization.
|
||||
// READ-GATE before any snapshot work or rate charging, through the shared
|
||||
// authorize_repo_read gate so quarantined repos are opaque here too. The
|
||||
// author fallback below needs the issue blob, which needs the repo tree, so
|
||||
// authorship cannot be established without a download; but a caller who
|
||||
// cannot even READ the repo must be stopped here, cheaply, before any
|
||||
// Tigris transfer or extraction happens. Without this, any signed non-owner
|
||||
// could issue parallel close requests for arbitrary issue ids and drive
|
||||
// unbounded downloads and blocking extraction (a disposable-identity DoS),
|
||||
// because the route has no other pre-authorization.
|
||||
//
|
||||
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden repo
|
||||
// from a missing one (INV-12 read-denial status contract).
|
||||
// Rate limiting runs AFTER this gate so a 429 cannot distinguish a hidden
|
||||
// repo from a missing one (INV-12 read-denial status contract).
|
||||
//
|
||||
// mirror-rows-handled: a repo row synced from a peer is stored public and
|
||||
// carries none of the owner's visibility rules, so for such a row this check
|
||||
// can only return allow. That is deliberate rather than overlooked, and it is
|
||||
// the same verdict every other read gate in this API reaches for one. Refusing
|
||||
// instead would deny the repo's real owner and the issue's real author on any
|
||||
// node whose only copy of the repo is a synced one, which is an ordinary state
|
||||
// here, and it would not buy the protection it appears to, because a synced
|
||||
// row's recorded owner comes from the peer that sent it. The expensive work
|
||||
// this check guards is bounded ahead of it by the per-IP limiter and the read
|
||||
// pool slot taken below, and the authoritative owner-or-author decision still
|
||||
// runs below and again under the write lock.
|
||||
{
|
||||
// mirror-rows-handled: synced mirror rows carry no owner rules; read gate only.
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.0.as_str();
|
||||
if crate::visibility::visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some(caller),
|
||||
"/",
|
||||
) == crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
}
|
||||
// carries none of the owner's visibility rules, so for such a row this gate
|
||||
// can only return allow. That is deliberate rather than overlooked, and it
|
||||
// is the same verdict every other read gate in this API reaches for one.
|
||||
// Refusing instead would deny the repo's real owner and the issue's real
|
||||
// author on any node whose only copy of the repo is a synced one, which is
|
||||
// an ordinary state here, and it would not buy the protection it appears
|
||||
// to, because a synced row's recorded owner comes from the peer that sent
|
||||
// it. The expensive work this check guards is bounded ahead of it by the
|
||||
// per-IP limiter and the read pool slot taken below, and the authoritative
|
||||
// owner-or-author decision still runs below and again under the write lock.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Per-IP flood brake, layered on the same shared limiter and trusted-proxy
|
||||
// policy as the push advertisement. The pre-lock snapshot downloads the
|
||||
@@ -502,6 +485,15 @@ mod tests {
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("repo exists");
|
||||
// upsert_mirror_repo seeds the row quarantined, and the read gate now
|
||||
// hides quarantined rows as opaque 404 before the owner-or-author check
|
||||
// this test names. Release it so the stranger is refused by the 403, not
|
||||
// by the quarantine short-circuit.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&record.id, false)
|
||||
.await
|
||||
.expect("release seed quarantine");
|
||||
|
||||
// An independent session holds the repo's write lock for the whole call.
|
||||
let key = crate::git::repo_store::advisory_lock_key_for_test(
|
||||
@@ -703,6 +695,15 @@ mod tests {
|
||||
.await
|
||||
.expect("get_repo")
|
||||
.expect("seeded repo exists");
|
||||
// upsert_mirror_repo seeds the admission flag as quarantined, but the
|
||||
// gate now hides quarantined rows from every caller class. Release it
|
||||
// so these tests exercise what they name: the owner-or-author check
|
||||
// after the read gate, not the quarantine short-circuit.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&record.id, false)
|
||||
.await
|
||||
.expect("release seed quarantine");
|
||||
let _ = owner_did;
|
||||
let path = state
|
||||
.repo_store
|
||||
@@ -728,6 +729,40 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
/// The read gate blocks the owner (and any caller) before the
|
||||
/// owner-or-author checks: even the owner gets the opaque 404 on a
|
||||
/// quarantined repo, and the seeded row's quarantined flag is left set
|
||||
/// deliberately. The released-mirror variants above exercise the
|
||||
/// owner-or-author path.
|
||||
#[sqlx::test]
|
||||
async fn quarantined_repo_close_is_opaque_404(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner_did = "did:key:z6MkT1Owner";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo("z6MkT1Owner", "tquar", "/unused", None, true)
|
||||
.await
|
||||
.expect("seed repo row");
|
||||
|
||||
let res = close_issue(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::Extension(crate::auth::AuthenticatedDid(owner_did.to_string())),
|
||||
axum::extract::Path((
|
||||
"z6MkT1Owner".to_string(),
|
||||
"tquar".to_string(),
|
||||
"1".to_string(),
|
||||
)),
|
||||
axum::http::HeaderMap::new(),
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.70:5000".parse().unwrap())),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::AppError::RepoNotFound(_))),
|
||||
"a quarantined repo must 404 close_issue for every caller: {:?}",
|
||||
res.ok().map(|_| "unexpected success")
|
||||
);
|
||||
}
|
||||
|
||||
/// INV-21(c) positive twin 1: the OWNER can still close. The reorder moved the
|
||||
/// owner check above the lock, so this is the arm most likely to have broken,
|
||||
/// and the deny test alone could not see it.
|
||||
|
||||
@@ -44,11 +44,8 @@ pub async fn add_label(
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let label = canonicalize_label(&req.label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let added = state.db.add_label(&record.id, &label).await?;
|
||||
@@ -71,11 +68,8 @@ pub async fn remove_label(
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let label = canonicalize_label(&label)?;
|
||||
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
let removed = state.db.remove_label(&record.id, &label).await?;
|
||||
|
||||
@@ -69,16 +69,9 @@ pub(crate) async fn authorize_repo_read(
|
||||
/// `did:web` / `did:gitlawb` share the base58 space with `did:key`, so a
|
||||
/// trailing-segment compare would treat `did:key:X` and `did:gitlawb:X` as equal.
|
||||
pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
fn key_id(d: &str) -> &str {
|
||||
d.strip_prefix("did:key:").unwrap_or(d)
|
||||
}
|
||||
let (ka, kb) = (key_id(a), key_id(b));
|
||||
// After stripping `did:key:`, a value still containing ':' is a non-key full
|
||||
// DID — do not let it match a bare `did:key` id.
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
// One implementation, shared with `gl` and `git-remote-gitlawb` through core:
|
||||
// every boundary that compares a DID applies the same collapse rule.
|
||||
gitlawb_core::ucan::push::did_key_eq(a, b)
|
||||
}
|
||||
|
||||
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
|
||||
@@ -177,16 +170,28 @@ mod authz_guard {
|
||||
"\nasync fn ",
|
||||
"\npub fn ",
|
||||
"\nfn ",
|
||||
"#[cfg(test)]",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|p| rest[1..].find(p).map(|i| i + 1))
|
||||
.min()
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end]
|
||||
// Strip block comments too: a marker surviving only inside /* */ would
|
||||
// otherwise satisfy a row with the real gate deleted.
|
||||
// Line comments first: a `//` line containing `/*` must not open a
|
||||
// block range or real code (and gate markers) gets stripped with it.
|
||||
let mut body = rest[..end]
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.join("\n");
|
||||
while let Some(open) = body.find("/*") {
|
||||
match body[open..].find("*/").map(|i| open + i + 2) {
|
||||
Some(close) => body.replace_range(open..close, ""),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -209,9 +214,14 @@ mod authz_guard {
|
||||
|
||||
// (source, handler, expected gate marker)
|
||||
let rows: &[(&str, &str, &str)] = &[
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403)
|
||||
// Bucket A — owner-gate (require_repo_owner -> 403), now behind
|
||||
// the shared read gate first so a hidden repo 404s for every
|
||||
// caller class before the owner check can answer 403.
|
||||
(pulls, "merge_pr", "authorize_repo_read("),
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "authorize_repo_read("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "authorize_repo_read("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
// Bucket A/B hybrid — list_webhooks is read-visibility THEN owner:
|
||||
// authorize_repo_read 404s a non-reader of a private repo, then
|
||||
@@ -220,10 +230,15 @@ mod authz_guard {
|
||||
// (existence hiding) and require_repo_owner guards the owner half.
|
||||
(webhooks, "list_webhooks", "authorize_repo_read("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
(labels, "add_label", "authorize_repo_read("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "authorize_repo_read("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
// Bucket A' — owner OR author (did_matches against the author)
|
||||
// Bucket A' — owner OR author (did_matches against the author),
|
||||
// also behind the shared read gate first.
|
||||
(pulls, "close_pr", "authorize_repo_read("),
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "authorize_repo_read("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
// Bucket B — read-gate (authorize_repo_read)
|
||||
(pulls, "create_review", "authorize_repo_read("),
|
||||
@@ -269,21 +284,66 @@ mod authz_guard {
|
||||
(bounties, "dispute_bounty", "did_matches("),
|
||||
(replicas, "register_replica", "did_matches("),
|
||||
(replicas, "unregister_replica", "replica_did = &auth.0"),
|
||||
// PRE-GATED — already owner-gated, in-scope group; guard the gate itself
|
||||
// PRE-GATED — owner-gated behind the shared read gate; guard both
|
||||
(protect, "protect_branch", "authorize_repo_read("),
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "authorize_repo_read("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
(visibility, "set_visibility", "authorize_repo_read("),
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "authorize_repo_read("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "authorize_repo_read("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
];
|
||||
|
||||
// The visibility rows prove require_owner is CALLED; this proves the helper
|
||||
// itself does DID-safe matching, not a raw/trailing-segment compare.
|
||||
// Rows above prove each visibility handler calls authorize_repo_read and
|
||||
// require_owner; this proves the helper itself does DID-safe matching.
|
||||
assert!(
|
||||
fn_body(visibility, "require_owner").contains("did_matches("),
|
||||
"visibility::require_owner must use did_matches for DID-safe owner matching"
|
||||
);
|
||||
|
||||
// Order matters as much as presence: hoisting the owner/author check
|
||||
// above the read gate would 403 a non-owner on a hidden repo, an
|
||||
// existence oracle. Pin read-gate-then-owner on every handler that
|
||||
// takes both; the owner marker differs per bucket (require_repo_owner
|
||||
// for owner-only, require_owner for visibility, did_matches for the
|
||||
// owner-or-author and protect paths).
|
||||
for (src, func, owner_marker) in [
|
||||
(visibility, "set_visibility", "require_owner("),
|
||||
(visibility, "remove_visibility", "require_owner("),
|
||||
(visibility, "list_visibility", "require_owner("),
|
||||
(pulls, "merge_pr", "require_repo_owner("),
|
||||
(webhooks, "list_webhooks", "require_repo_owner("),
|
||||
(webhooks, "create_webhook", "require_repo_owner("),
|
||||
(webhooks, "delete_webhook", "require_repo_owner("),
|
||||
(labels, "add_label", "require_repo_owner("),
|
||||
(labels, "remove_label", "require_repo_owner("),
|
||||
(pulls, "close_pr", "did_matches("),
|
||||
(issues, "close_issue", "did_matches("),
|
||||
(protect, "protect_branch", "did_matches("),
|
||||
(protect, "unprotect_branch", "did_matches("),
|
||||
] {
|
||||
let body = fn_body(src, func);
|
||||
let gate = body.find("authorize_repo_read(").expect("gate marker");
|
||||
let owner = body.find(owner_marker).expect("owner marker");
|
||||
assert!(
|
||||
gate < owner,
|
||||
"{func} must run authorize_repo_read before {owner_marker}"
|
||||
);
|
||||
// The owner-or-author and protect handlers also carry
|
||||
// require_repo_owner: a hoist of that call above the read gate is
|
||||
// the same 403-vs-404 oracle, so pin it too where it exists.
|
||||
if func == "close_pr" || func == "close_issue" {
|
||||
let owner_gate = body.find("require_repo_owner(").expect("owner marker");
|
||||
assert!(
|
||||
gate < owner_gate,
|
||||
"{func} must run authorize_repo_read before require_repo_owner("
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The CID read surface (#173) enforces its gate inside the shared
|
||||
// `gate_and_serve`, which BOTH the provenance path and the legacy scan call, so
|
||||
// the markers must live there (the get_by_cid row above only proves delegation).
|
||||
|
||||
@@ -17,11 +17,8 @@ pub async fn protect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Only the repo owner can protect branches (DID-safe match, shared idiom).
|
||||
let caller = &auth.0;
|
||||
@@ -51,11 +48,8 @@ pub async fn unprotect_branch(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo, branch)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let caller = &auth.0;
|
||||
if !crate::api::did_matches(caller, &record.owner_did) {
|
||||
|
||||
@@ -95,6 +95,7 @@ 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!({
|
||||
@@ -199,11 +200,8 @@ pub async fn merge_pr(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
// Owner-only merge (N7). Merging writes the served tree, so this is the same
|
||||
// trust boundary as owner-only push; it subsumes branch protection (a
|
||||
@@ -249,6 +247,7 @@ 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!({
|
||||
@@ -274,11 +273,8 @@ pub async fn close_pr(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, number)): Path<(String, String, i64)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
|
||||
let pr = state
|
||||
.db
|
||||
@@ -300,6 +296,7 @@ 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!({
|
||||
@@ -350,6 +347,7 @@ 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!({
|
||||
|
||||
@@ -594,10 +594,11 @@ pub async fn get_tree(
|
||||
// ── Git smart HTTP endpoints ──────────────────────────────────────────────
|
||||
|
||||
fn smart_http_repo_name(repo: &str) -> Result<&str> {
|
||||
// Strip at most one ".git" suffix: trim_end_matches strips repeatedly,
|
||||
// which would misdirect a repo literally named "foo.git" (creatable via
|
||||
// the peer mirror path, which skips API name validation) to repo "foo".
|
||||
let name = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
// Strip at most one ".git" suffix — shared with the UCAN push matcher
|
||||
// (`canonical_repo_name`), so a delegation and a remote URL name a
|
||||
// repository the same way. At-most-once matters: a repo literally named
|
||||
// "foo.git" is addressed as "foo.git.git".
|
||||
let name = gitlawb_core::ucan::push::canonical_repo_name(repo);
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("missing repository name".into()));
|
||||
}
|
||||
@@ -2074,14 +2075,21 @@ fn owner_push_rejection(
|
||||
enforce: bool,
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: Option<&str>,
|
||||
verified: Option<&crate::auth::VerifiedUcan>,
|
||||
) -> Option<AppError> {
|
||||
if !enforce {
|
||||
return None;
|
||||
}
|
||||
match caller {
|
||||
Some(did) if caller_authorized_to_push(record, did) => None,
|
||||
Some(did) if caller_authorized_to_push(record, did, verified) => None,
|
||||
// One message for every refusal. It must not say whether a delegation was
|
||||
// presented, was expired, or named another repository: varying it would turn
|
||||
// the denial into an oracle for which capabilities exist. It only has to be
|
||||
// TRUE in all of those cases, which the previous owner-only wording no
|
||||
// longer was once a delegation could authorize a push.
|
||||
_ => Some(AppError::Forbidden(
|
||||
"push rejected — only the repo owner may push to this repository \
|
||||
"push rejected — you must be the repo owner, or hold a valid \
|
||||
owner-issued git/push delegation for this repository \
|
||||
(GITLAWB_ENFORCE_OWNER_PUSH is enabled)"
|
||||
.into(),
|
||||
)),
|
||||
@@ -2264,6 +2272,10 @@ pub async fn git_receive_pack(
|
||||
State(state): State<AppState>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
// `X-Ucan` is optional, so the extension may be absent: axum extracts that as
|
||||
// `None` rather than rejecting the request. Present only when the middleware
|
||||
// validated a chain.
|
||||
verified: Option<Extension<crate::auth::VerifiedUcan>>,
|
||||
crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: Bytes,
|
||||
@@ -2294,6 +2306,25 @@ pub async fn git_receive_pack(
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
|
||||
// The reader gate applies to the write path too, and the POST cannot lean
|
||||
// on info/refs having checked it: a caller can skip the advertisement and
|
||||
// hit this route directly. Without the check here a delegate (or any signed
|
||||
// caller with a valid signature) could write to a private repo it cannot
|
||||
// read, which is precisely the "delegates must also be readers" rule the
|
||||
// advertisement already enforces.
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
if visibility_check(
|
||||
&rules,
|
||||
record.is_public,
|
||||
&record.owner_did,
|
||||
Some(auth.0.as_str()),
|
||||
"/",
|
||||
) == Decision::Deny
|
||||
{
|
||||
tracing::debug!(repo = %name, caller = %auth.0, "receive-pack denied by visibility");
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{name}")));
|
||||
}
|
||||
|
||||
// Parse ref updates from pkt-line body before handing to git. The count
|
||||
// cap and duplicate-refusal live inside `parse_ref_updates`, so both fire
|
||||
// before any per-request materialization, gate, or durable write below.
|
||||
@@ -2313,6 +2344,7 @@ pub async fn git_receive_pack(
|
||||
state.config.enforce_owner_push,
|
||||
&record,
|
||||
Some(auth.0.as_str()),
|
||||
verified.as_ref().map(|Extension(v)| v),
|
||||
) {
|
||||
tracing::warn!(
|
||||
repo = %name,
|
||||
@@ -2324,31 +2356,52 @@ pub async fn git_receive_pack(
|
||||
}
|
||||
|
||||
// ── Branch protection check ──────────────────────────────────────────
|
||||
// Uses the same verified identity as the owner-push gate above. (When that
|
||||
// gate is enabled a non-owner never reaches here; this still applies when it
|
||||
// is off, gating only the branches an owner has explicitly protected.)
|
||||
// Uses the same verified identity as the owner-push gate above, but a STRICTER
|
||||
// predicate: owner-only, deliberately not `caller_authorized_to_push`.
|
||||
//
|
||||
// A delegate can therefore clear the gate above and still be refused here. That
|
||||
// is the intended policy, not an oversight: a protected branch is the owner's
|
||||
// explicit marker that even routine writes should stop, so a `git/push`
|
||||
// delegation must not silently override it. Widening this to accept a
|
||||
// delegation would make every existing protection weaker the moment the owner
|
||||
// issues any capability.
|
||||
//
|
||||
// `delegated_push_is_still_refused_on_a_protected_branch` pins this.
|
||||
for update in &ref_updates {
|
||||
// Strip refs/heads/ prefix to get plain branch name
|
||||
let branch = update
|
||||
.ref_name
|
||||
.strip_prefix("refs/heads/")
|
||||
.unwrap_or(&update.ref_name);
|
||||
if state
|
||||
.db
|
||||
.is_branch_protected(&record.id, branch)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
&& !crate::api::did_matches(&auth.0, &record.owner_did)
|
||||
{
|
||||
tracing::warn!(
|
||||
branch = %branch,
|
||||
pusher = %auth.0,
|
||||
owner_did = %record.owner_did,
|
||||
"branch protection: rejecting push from non-owner"
|
||||
);
|
||||
return Err(AppError::Forbidden(format!(
|
||||
"branch '{branch}' is protected — only the repo owner can push to it"
|
||||
)));
|
||||
if !crate::api::did_matches(&auth.0, &record.owner_did) {
|
||||
// A non-owner (delegated pusher included) must fail closed when the
|
||||
// protection lookup errors: treating Err as unprotected lets a
|
||||
// protected branch be pushed through a database hiccup.
|
||||
match state.db.is_branch_protected(&record.id, branch).await {
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
branch = %branch,
|
||||
pusher = %auth.0,
|
||||
owner_did = %record.owner_did,
|
||||
"branch protection: rejecting push from non-owner"
|
||||
);
|
||||
return Err(AppError::Forbidden(format!(
|
||||
"branch '{branch}' is protected — only the repo owner can push to it"
|
||||
)));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
branch = %branch,
|
||||
pusher = %auth.0,
|
||||
err = %e,
|
||||
"branch protection lookup failed; refusing non-owner push"
|
||||
);
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"failed to check branch protection: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6719,7 +6772,7 @@ mod tests {
|
||||
#[test]
|
||||
fn enforced_allows_owner_full_did() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID)).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_DID), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6727,28 +6780,84 @@ mod tests {
|
||||
// Owners are accepted in bare-multibase form, matching the rest of the
|
||||
// codebase's owner comparisons.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT)).is_none());
|
||||
assert!(owner_push_rejection(true, &repo, Some(OWNER_SHORT), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_non_owner_with_forbidden() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID)));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_missing_did_with_forbidden() {
|
||||
// Fail closed: an absent authenticated identity is rejected, not allowed.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, None));
|
||||
assert_forbidden(owner_push_rejection(true, &repo, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_allows_non_owner_and_missing_did() {
|
||||
// Flag off → legacy behavior: authentication-only, no owner gate.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID)).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, None).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, Some(STRANGER_DID), None).is_none());
|
||||
assert!(owner_push_rejection(false, &repo, None, None).is_none());
|
||||
}
|
||||
|
||||
/// Build a VerifiedUcan whose chain roots at `root_did` and which carries
|
||||
/// `git/push` for `repo`. The token's own issuer and audience do not matter
|
||||
/// here: the middleware has already bound them before this gate is reached.
|
||||
fn push_delegation(root_did: &str, repo: &crate::db::RepoRecord) -> crate::auth::VerifiedUcan {
|
||||
let agent = gitlawb_core::identity::Keypair::generate();
|
||||
let node = gitlawb_core::identity::Keypair::generate();
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{}/{}", repo.owner_did, repo.name),
|
||||
gitlawb_core::ucan::caps::GIT_PUSH,
|
||||
)],
|
||||
// Finite: a write capability that never lapses is refused, since there
|
||||
// is no revocation path to withdraw a leaked one.
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
crate::auth::VerifiedUcan {
|
||||
ucan,
|
||||
root: root_did.parse().expect("root DID must parse"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_allows_a_non_owner_holding_an_owner_rooted_push_capability() {
|
||||
// The regression owner-only push introduced: a CI or delegated key with a
|
||||
// valid capability was refused exactly like a stranger.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
let verified = push_delegation(OWNER_DID, &repo);
|
||||
assert!(
|
||||
owner_push_rejection(true, &repo, Some(STRANGER_DID), Some(&verified)).is_none(),
|
||||
"a delegation rooted at the owner must let a non-owner push"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_rejects_a_delegation_rooted_at_a_stranger() {
|
||||
// Anchoring is the whole point: a chain nobody the repo trusts started
|
||||
// grants nothing, even carrying a perfectly formed push capability.
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
let verified = push_delegation(STRANGER_DID, &repo);
|
||||
assert_forbidden(owner_push_rejection(
|
||||
true,
|
||||
&repo,
|
||||
Some(STRANGER_DID),
|
||||
Some(&verified),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforced_still_rejects_a_non_owner_with_no_capability() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert_forbidden(owner_push_rejection(true, &repo, Some(STRANGER_DID), None));
|
||||
}
|
||||
|
||||
/// Policy 2 carve-out: replication/Tigris follows landed report
|
||||
@@ -6819,9 +6928,9 @@ mod tests {
|
||||
#[test]
|
||||
fn caller_authorized_to_push_is_owner_only_in_phase_1() {
|
||||
let repo = repo_owned_by(OWNER_DID);
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_DID));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_DID, None));
|
||||
assert!(caller_authorized_to_push(&repo, OWNER_SHORT, None));
|
||||
assert!(!caller_authorized_to_push(&repo, STRANGER_DID, None));
|
||||
}
|
||||
|
||||
// ── fork_withheld_blocks (#98 path-scoped fork gate) ──
|
||||
@@ -9342,6 +9451,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6rp4wr".to_string(), "rp4".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(capped)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9360,6 +9470,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6rp4wr".to_string(), "rp4".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(other)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9466,6 +9577,7 @@ mod tests {
|
||||
State(state_for_task),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9553,6 +9665,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -9617,6 +9730,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("1111111111111111111111111111111111111111"),
|
||||
@@ -9817,6 +9931,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid("did:key:z6disc".to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.81:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -9975,6 +10090,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid("did:key:z6succ".to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.83:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -10069,6 +10185,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(pusher.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.84:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("5555555555555555555555555555555555555555"),
|
||||
@@ -10164,6 +10281,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -10182,6 +10300,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.72:5000".parse().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -10847,6 +10966,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(pusher.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body_many(MAX_REF_UPDATES + 1),
|
||||
@@ -10916,6 +11036,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(pusher.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body_many(MAX_REF_UPDATES),
|
||||
@@ -10996,6 +11117,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body_cmds(&[
|
||||
@@ -11071,6 +11193,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6noreport".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("3333333333333333333333333333333333333333"),
|
||||
@@ -11172,6 +11295,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6empty".to_string(), "e1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid("did:key:z6empty".to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -11275,6 +11399,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6unpack".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("4444444444444444444444444444444444444444"),
|
||||
@@ -11405,6 +11530,7 @@ mod tests {
|
||||
State(state),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body(new_sha),
|
||||
@@ -11499,6 +11625,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4fast".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -11559,6 +11686,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f4park".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("2222222222222222222222222222222222222222"),
|
||||
@@ -12784,6 +12912,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f3repo".to_string(), "r1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -12812,6 +12941,7 @@ mod tests {
|
||||
State(state_b),
|
||||
Path(("z6f3repo".to_string(), "r1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.82:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -12908,6 +13038,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6f3clean".to_string(), "c1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer.parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13001,6 +13132,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f3dos".to_string(), "d1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13028,6 +13160,7 @@ mod tests {
|
||||
State(state_b),
|
||||
Path(("z6f3dos".to_string(), "d1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(
|
||||
"203.0.113.72:5000".parse::<SocketAddr>().unwrap(),
|
||||
)),
|
||||
@@ -13212,6 +13345,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u2key".to_string(), "k1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13285,6 +13419,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6ovflow".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
ref_update_body("1111111111111111111111111111111111111111"),
|
||||
@@ -13339,6 +13474,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u1cap".to_string(), "c1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13444,6 +13580,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6u1two".to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(src)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13534,6 +13671,7 @@ mod tests {
|
||||
State(st),
|
||||
Path((owner.to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(edge)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13627,6 +13765,7 @@ mod tests {
|
||||
State(st),
|
||||
Path(("z6f1key".to_string(), repo.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(peer)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13687,6 +13826,7 @@ mod tests {
|
||||
Extension(crate::auth::AuthenticatedDid(
|
||||
"did:key:z6f1none".to_string(),
|
||||
)),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(None),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -13731,6 +13871,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path(("z6f1seq".to_string(), "s1".to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(did.to_string())),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some(src)),
|
||||
axum::http::HeaderMap::new(),
|
||||
axum::body::Bytes::from_static(b"0000"),
|
||||
@@ -14311,6 +14452,7 @@ mod tests {
|
||||
State(state.clone()),
|
||||
Path((owner.to_string(), name.to_string())),
|
||||
Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))),
|
||||
None,
|
||||
crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::<SocketAddr>().unwrap())),
|
||||
axum::http::HeaderMap::new(),
|
||||
body,
|
||||
|
||||
@@ -1296,6 +1296,60 @@ mod visible_tasks_tests {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// An over-ceiling limit caps at MAX_VISIBLE_TASKS rather than scanning or
|
||||
/// serializing the whole table (#405, adapted to the cursor handler).
|
||||
#[sqlx::test]
|
||||
async fn limit_ceiling_caps_at_200(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
for i in 0..201 {
|
||||
state
|
||||
.db
|
||||
.create_task(&task(&format!("ceil-{i}"), None, DELEGATOR))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = list_router(state)
|
||||
.oneshot(signed_request_as(
|
||||
DELEGATOR,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?limit=9223372036854775807",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(
|
||||
body["count"], 200,
|
||||
"i64::MAX must clamp to the 200 ceiling, not scan the table"
|
||||
);
|
||||
}
|
||||
|
||||
/// An omitted limit falls back to the 50-row default (#405).
|
||||
#[sqlx::test]
|
||||
async fn omitted_limit_uses_default_50(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
for i in 0..60 {
|
||||
state
|
||||
.db
|
||||
.create_task(&task(&format!("def-{i}"), None, DELEGATOR))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let resp = list_router(state)
|
||||
.oneshot(signed_request_as(
|
||||
DELEGATOR,
|
||||
Method::GET,
|
||||
"/api/v1/tasks",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["count"], 50, "omitted limit must default to 50");
|
||||
}
|
||||
|
||||
/// A negative limit must clamp to zero through `collect_visible_tasks`,
|
||||
/// not fall through to the visible set.
|
||||
#[sqlx::test]
|
||||
|
||||
@@ -85,11 +85,10 @@ pub async fn set_visibility(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<SetVisibilityRequest>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Quarantine first (via authorize_repo_read), then owner — same posture as
|
||||
// list_visibility so a quarantined repo cannot be mutated while reads 404.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
validate_path_glob(&req.path_glob)?;
|
||||
|
||||
@@ -141,11 +140,8 @@ pub async fn remove_visibility(
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
Json(req): Json<RemoveVisibilityRequest>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
state
|
||||
@@ -171,14 +167,12 @@ pub async fn list_visibility(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
// Quarantine first (via authorize_repo_read), then owner — a quarantined
|
||||
// mirror must be opaque even to a caller matching owner_did.
|
||||
let (record, rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(&auth.0), "/").await?;
|
||||
require_owner(&record, &auth.0)?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let rules_json: Vec<_> = rules
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
@@ -205,28 +199,18 @@ pub async fn list_visibility(
|
||||
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
|
||||
/// denied subtrees while re-including the allowed nested paths. Unlike
|
||||
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
|
||||
///
|
||||
/// Quarantined repos are opaque 404 via [`crate::api::authorize_repo_read`] —
|
||||
/// same posture as encrypted-blob discovery — so admission and private-subtree
|
||||
/// layout are not disclosed to anon, owner, or peers.
|
||||
pub async fn withheld_paths(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let rules = state.db.list_visibility_rules(&record.id).await?;
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
|
||||
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
|
||||
// matching the git read endpoints, so this never discloses a private repo's
|
||||
// existence or its path layout to an unauthorized caller.
|
||||
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
|
||||
== crate::visibility::Decision::Deny
|
||||
{
|
||||
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
let (record, rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
let withheld =
|
||||
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
|
||||
|
||||
@@ -33,11 +33,8 @@ pub async fn create_webhook(
|
||||
Path((owner, name)): Path<(String, String)>,
|
||||
Json(req): Json<CreateWebhookRequest>,
|
||||
) -> Result<(StatusCode, Json<Webhook>)> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Gate the target through the same hardened public-host validator the peer
|
||||
@@ -59,7 +56,14 @@ pub async fn create_webhook(
|
||||
id: Uuid::new_v4().to_string(),
|
||||
repo_id: record.id,
|
||||
url: req.url,
|
||||
secret: req.secret,
|
||||
// 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,
|
||||
))
|
||||
}),
|
||||
events,
|
||||
created_by_did,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
@@ -96,19 +100,11 @@ pub async fn list_webhooks(
|
||||
crate::api::require_repo_owner(&record, &caller)?;
|
||||
|
||||
let hooks = state.db.list_webhooks(&record.id).await?;
|
||||
// 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();
|
||||
|
||||
// `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();
|
||||
Ok(Json(
|
||||
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
|
||||
serde_json::json!({ "webhooks": hooks, "count": count }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -118,11 +114,8 @@ pub async fn delete_webhook(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, name, id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
|
||||
crate::api::require_repo_owner(&record, &auth.0)?;
|
||||
|
||||
// Verify the webhook belongs to this repo
|
||||
@@ -155,6 +148,7 @@ 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
|
||||
@@ -232,4 +226,137 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,74 @@ use crate::state::AppState;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedDid(pub String);
|
||||
|
||||
/// A UCAN that passed full chain validation, with the root issuer the chain
|
||||
/// rests on. Inserted into request extensions by [`require_ucan_chain`] when
|
||||
/// `X-Ucan` is present; absent when the header is.
|
||||
///
|
||||
/// `root` is carried rather than recomputed so the chain is walked once per
|
||||
/// request. Holding this is not itself an authorization decision — a caller must
|
||||
/// still compare `root` against an identity it independently trusts, because
|
||||
/// `did:key` is self-certifying and anyone can mint a chain that verifies.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VerifiedUcan {
|
||||
pub ucan: Ucan,
|
||||
pub root: Did,
|
||||
}
|
||||
|
||||
/// Whether `caller` is authorized to push to `record`.
|
||||
///
|
||||
/// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical
|
||||
/// [`crate::api::did_matches`] owner comparison (DID-safe on both sides). This is
|
||||
/// intentionally a distinct, intent-named gate rather than a bare owner check so
|
||||
/// that Phase 2 can extend it to honor a verified UCAN `git/push` capability as a
|
||||
/// pure addition (`did_matches(..) || ucan_grants_push(..)`) without rewriting
|
||||
/// call sites.
|
||||
pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) -> bool {
|
||||
/// The repo owner, or a caller presenting a verified UCAN whose chain roots at
|
||||
/// that owner and which carries `git/push` for this repo.
|
||||
///
|
||||
/// `verified` is optional because `X-Ucan` is: a push carrying no token reaches
|
||||
/// the same owner-only decision it always did. The owner check is unconditional
|
||||
/// and runs first, so this can only ever turn a refusal into an acceptance,
|
||||
/// never the reverse.
|
||||
pub fn caller_authorized_to_push(
|
||||
record: &crate::db::RepoRecord,
|
||||
caller: &str,
|
||||
verified: Option<&VerifiedUcan>,
|
||||
) -> bool {
|
||||
crate::api::did_matches(caller, &record.owner_did)
|
||||
|| verified.is_some_and(|v| ucan_grants_push(record, v))
|
||||
}
|
||||
|
||||
/// Whether a verified UCAN authorizes a push to `record`.
|
||||
///
|
||||
/// Three conditions, all required:
|
||||
/// 1. The chain roots at this repo's owner. This is the trust anchor — the
|
||||
/// repo record is data the node holds independently of the token, so a
|
||||
/// self-minted chain cannot satisfy it.
|
||||
/// 2. Every link is bounded. There is no revocation path, so an unbounded link
|
||||
/// is a permanent grant.
|
||||
/// 3. Every link — the leaf and every proof behind it — carries an
|
||||
/// unconstrained push-class capability naming this repository. That is
|
||||
/// [`gitlawb_core::ucan::Ucan::chain_grants_push_to`], the same rule
|
||||
/// `gl ucan import` applies before storing a token and `git-remote-gitlawb`
|
||||
/// applies before minting an invocation, so what one boundary accepts the
|
||||
/// next does not refuse.
|
||||
///
|
||||
/// (3) walks the whole chain on purpose. Refusing a wildcard leaf alone did
|
||||
/// nothing: `is_attenuated_by` accepts a concrete child under a `*` parent, and
|
||||
/// that narrowing was exactly what the helper performed, so one owner-issued
|
||||
/// `with: "*"` proof let a delegate mint a concrete leaf for ANY repository the
|
||||
/// owner had — or created later — and both the chain check and the owner-root
|
||||
/// check passed. A delegation's scope is fixed when it is issued.
|
||||
pub fn ucan_grants_push(record: &crate::db::RepoRecord, verified: &VerifiedUcan) -> bool {
|
||||
if !crate::api::did_matches(&verified.root.to_string(), &record.owner_did) {
|
||||
return false;
|
||||
}
|
||||
// A write capability must lapse on its own. `exp` is optional in the format and
|
||||
// there is no revocation path, so a chain with an unbounded link is a permanent
|
||||
// grant: once the token leaks, the owner cannot withdraw it short of rotating
|
||||
// the DID the repo is keyed on. Refusing here is what makes "the damage window
|
||||
// is the token's expiry" a true statement rather than an aspiration.
|
||||
if !verified.ucan.chain_lifetime_is_bounded() {
|
||||
return false;
|
||||
}
|
||||
verified
|
||||
.ucan
|
||||
.chain_grants_push_to(&record.owner_did, &record.name)
|
||||
}
|
||||
|
||||
use gitlawb_core::http_sig::{
|
||||
@@ -355,7 +413,7 @@ fn validate_ucan_chain(
|
||||
token: &str,
|
||||
expected_aud: &Did,
|
||||
signer_did: &Did,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<VerifiedUcan, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ucan = Ucan::decode(token).map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -383,14 +441,14 @@ fn validate_ucan_chain(
|
||||
)
|
||||
})?;
|
||||
|
||||
ucan.verify_chain().map_err(|e| {
|
||||
let root = ucan.verify_chain().map_err(|e| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
Ok(VerifiedUcan { ucan, root })
|
||||
}
|
||||
|
||||
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
|
||||
@@ -443,11 +501,18 @@ pub async fn require_ucan_chain(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
return (status, body).into_response();
|
||||
}
|
||||
let verified = match validate_ucan_chain(&token, &state.node_did, &signer_did) {
|
||||
Ok(v) => v,
|
||||
Err((status, body)) => return (status, body).into_response(),
|
||||
};
|
||||
|
||||
tracing::debug!(did = %signer_did, "UCAN chain validated");
|
||||
tracing::debug!(did = %signer_did, root = %verified.root, "UCAN chain validated");
|
||||
|
||||
// Park the verified token where a handler can reach it. Validation alone
|
||||
// grants nothing; the authorization decision is made downstream, by a caller
|
||||
// that knows which identity it trusts for the resource being touched.
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(verified);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -483,6 +548,37 @@ mod tests {
|
||||
Ucan::bootstrap(node, agent_did).unwrap()
|
||||
}
|
||||
|
||||
/// The middleware validated a token and threw the result away, so no handler
|
||||
/// could ever read it and `Ucan::can` had no call site in the node. Validation
|
||||
/// must hand back both the token and the root the chain rests on.
|
||||
#[test]
|
||||
fn validate_ucan_chain_hands_back_the_root_and_the_token() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let caps_vec = vec![Capability::new("gitlawb://repos/zowner/r", caps::GIT_PUSH)];
|
||||
|
||||
let delegation =
|
||||
Ucan::issue(&owner, agent.did(), caps_vec.clone(), None).expect("issue delegation");
|
||||
let invocation = Ucan::delegate(&agent, node.did(), caps_vec, None, &delegation)
|
||||
.expect("wrap invocation");
|
||||
let token = invocation.encode().expect("encode");
|
||||
|
||||
let verified = validate_ucan_chain(&token, &node.did(), &agent.did())
|
||||
.expect("a well-formed owner-rooted invocation must validate");
|
||||
|
||||
assert_eq!(
|
||||
verified.root,
|
||||
owner.did(),
|
||||
"the root must be the owner, so a caller can anchor against the repo record"
|
||||
);
|
||||
assert_eq!(
|
||||
verified.ucan.payload.iss,
|
||||
agent.did(),
|
||||
"the token itself must come back so a caller can read its capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
|
||||
Ucan::delegate(
|
||||
agent,
|
||||
@@ -628,6 +724,7 @@ 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()
|
||||
@@ -667,6 +764,7 @@ 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)),
|
||||
@@ -854,3 +952,435 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_push_tests {
|
||||
use super::*;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
|
||||
const OWNER_KEY: &str = "z6MkOwnerAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
/// `RepoRecord` does not derive `Default`, and adding the derive to a
|
||||
/// production DB type purely to serve a test is the wrong direction.
|
||||
fn repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
|
||||
crate::db::RepoRecord {
|
||||
id: "repo-id".to_string(),
|
||||
name: name.to_string(),
|
||||
owner_did: owner_did.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
disk_path: "/unused".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token's own issuer and audience are irrelevant to this predicate: the
|
||||
/// middleware has already bound `iss` to the request signer and `aud` to this
|
||||
/// node. Only the capabilities and the chain's root matter here.
|
||||
fn verified(root: &str, caps_vec: Vec<Capability>) -> VerifiedUcan {
|
||||
verified_with_exp(
|
||||
root,
|
||||
caps_vec,
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
}
|
||||
|
||||
fn verified_with_exp(
|
||||
root: &str,
|
||||
caps_vec: Vec<Capability>,
|
||||
exp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> VerifiedUcan {
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let ucan = Ucan::issue(&agent, node.did(), caps_vec, exp).expect("issue");
|
||||
VerifiedUcan {
|
||||
ucan,
|
||||
root: root.parse().expect("root DID must parse"),
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_full() -> String {
|
||||
format!("did:key:{OWNER_KEY}")
|
||||
}
|
||||
|
||||
fn push_cap_for(owner: &str, name: &str) -> Capability {
|
||||
Capability::new(format!("gitlawb://repos/{owner}/{name}"), caps::GIT_PUSH)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grants_push_when_the_chain_roots_at_the_owner_and_names_the_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_a_bare_owner_key_against_a_full_did_record() {
|
||||
// Mirror rows store the bare key. A literal string compare would fail
|
||||
// here, denying a delegation that is in fact valid.
|
||||
let rec = repo(OWNER_KEY, "myrepo");
|
||||
let v = verified(&owner_full(), vec![push_cap_for(&owner_full(), "myrepo")]);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A perpetual grant is refused even when it is otherwise perfectly valid:
|
||||
/// owner-rooted, right repo, right action. Without revocation, an unbounded
|
||||
/// delegation cannot be withdrawn once it leaks.
|
||||
#[test]
|
||||
fn refuses_a_delegation_that_never_expires() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified_with_exp(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")],
|
||||
None,
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_self_minted_root() {
|
||||
// The whole point: a token nobody delegated grants nothing, however
|
||||
// permissive its capabilities look.
|
||||
let stranger = Keypair::generate();
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(&stranger.did().to_string(), vec![Capability::new("*", "*")]);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_for_a_different_repo() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "otherrepo")],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A delegation can be issued against the remote's spelling (`myrepo.git`):
|
||||
/// the matcher strips the same single `.git` the smart-HTTP routes do, so
|
||||
/// the capability lands on the canonical record name.
|
||||
#[test]
|
||||
fn grants_push_when_the_capability_carries_the_git_suffix() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo.git")],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// Mirror-created rows can carry a literal `.git` name; reaching one takes
|
||||
/// `foo.git.git`, the same spelling the remote URL uses. A single `.git`
|
||||
/// capability resolves to `foo` and must not fall through to `foo.git`.
|
||||
#[test]
|
||||
fn a_repo_literally_named_dot_git_needs_the_doubled_suffix() {
|
||||
let rec = repo(&owner_full(), "foo.git");
|
||||
let single = verified(&owner_full(), vec![push_cap_for(&owner_full(), "foo.git")]);
|
||||
assert!(!ucan_grants_push(&rec, &single));
|
||||
let doubled = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "foo.git.git")],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &doubled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_capability_carrying_constraints() {
|
||||
// `nb` is not interpreted yet. An owner who writes {"refs": [...]} means
|
||||
// to restrict; honouring the capability while ignoring nb would grant
|
||||
// strictly more than they intended, so it authorizes nothing.
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![push_cap_for(&owner_full(), "myrepo")
|
||||
.with_constraints(serde_json::json!({ "refs": ["refs/heads/feat/*"] }))],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_non_push_capability() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let v = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::ISSUE_CREATE,
|
||||
)],
|
||||
);
|
||||
assert!(!ucan_grants_push(&rec, &v));
|
||||
}
|
||||
|
||||
/// A resource wildcard must NOT authorize a push, even though attenuation
|
||||
/// accepts it. The helper narrows a `*` delegation before signing, but the node
|
||||
/// cannot rely on that: a delegate can sign an invocation that keeps the
|
||||
/// wildcard, and it would otherwise reach every repository the owner has — or
|
||||
/// will later create. This test previously asserted the opposite.
|
||||
#[test]
|
||||
fn refuses_a_resource_wildcard_and_honours_repo_admin() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(
|
||||
!ucan_grants_push(&rec, &wildcard),
|
||||
"a wildcard resource must not authorize a push at the node"
|
||||
);
|
||||
|
||||
// The ACTION wildcard is a different axis and stays: attenuation bounds it,
|
||||
// and it still has to name a concrete repository.
|
||||
let action_wildcard = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
"*",
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &action_wildcard));
|
||||
|
||||
let admin = verified(
|
||||
&owner_full(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{}/myrepo", owner_full()),
|
||||
caps::REPO_ADMIN,
|
||||
)],
|
||||
);
|
||||
assert!(ucan_grants_push(&rec, &admin));
|
||||
}
|
||||
|
||||
/// The attack the wildcard refusal exists to stop: one `*` delegation reaching a
|
||||
/// repository it was never issued against, including one created afterwards.
|
||||
#[test]
|
||||
fn a_wildcard_delegation_cannot_reach_a_second_repository() {
|
||||
let other = repo(&owner_full(), "a-repo-created-later");
|
||||
let wildcard = verified(&owner_full(), vec![Capability::new("*", caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&other, &wildcard));
|
||||
}
|
||||
|
||||
/// The round-8 P1, executed rather than reasoned: an owner-issued `*` PROOF with
|
||||
/// a concrete leaf for a repository that did not exist at issuance. Refusing a
|
||||
/// wildcard *leaf* did nothing here — `is_attenuated_by` accepts a concrete child
|
||||
/// under a `*` parent, and that narrowing is exactly what `build_invocation` did,
|
||||
/// so the first-party helper was the working mint path.
|
||||
#[test]
|
||||
fn a_wildcard_proof_cannot_reach_a_repo_created_later() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let later = format!("gitlawb://repos/{}/a-repo-created-later", owner.did());
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&later, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("the chain still verifies");
|
||||
let rec = repo(&owner.did().to_string(), "a-repo-created-later");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard proof must not authorize a repo it never named"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shipping flow must keep working: a proof that names the repository
|
||||
/// authorizes a push to it. Guards against fixing the wildcard by refusing
|
||||
/// everything with a `prf`.
|
||||
#[test]
|
||||
fn a_concrete_proof_still_authorizes_the_repo_it_names() {
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let parent = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// A `*` two links up. The immediate proof names the repository, so a walk that
|
||||
/// checks only one level of `prf` stays green here — this is the case that
|
||||
/// makes the recursion load-bearing rather than incidental.
|
||||
#[test]
|
||||
fn a_wildcard_grandparent_cannot_reach_the_repo_through_a_concrete_parent() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
|
||||
let grandparent = Ucan::issue(
|
||||
&owner,
|
||||
intermediary.did(),
|
||||
vec![Capability::new("*", caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap();
|
||||
let parent = Ucan::delegate(
|
||||
&intermediary,
|
||||
agent.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&grandparent,
|
||||
)
|
||||
.unwrap();
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
node.did(),
|
||||
vec![Capability::new(&resource, caps::GIT_PUSH)],
|
||||
Some(hour),
|
||||
&parent,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root = invocation.verify_chain().expect("chain verifies");
|
||||
assert_eq!(root, owner.did());
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(
|
||||
!ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
),
|
||||
"a wildcard anywhere in the chain must deny, not just in the immediate proof"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the same three-link shape with every link concrete must still grant, so
|
||||
/// the walk is refusing the wildcard and not the depth.
|
||||
#[test]
|
||||
fn a_three_link_concrete_chain_still_authorizes() {
|
||||
let owner = Keypair::generate();
|
||||
let intermediary = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
let grandparent = Ucan::issue(&owner, intermediary.did(), cap(), Some(hour)).unwrap();
|
||||
let parent =
|
||||
Ucan::delegate(&intermediary, agent.did(), cap(), Some(hour), &grandparent).unwrap();
|
||||
let invocation = Ucan::delegate(&agent, node.did(), cap(), Some(hour), &parent).unwrap();
|
||||
|
||||
let root = invocation.verify_chain().unwrap();
|
||||
let rec = repo(&owner.did().to_string(), "myrepo");
|
||||
assert!(ucan_grants_push(
|
||||
&rec,
|
||||
&VerifiedUcan {
|
||||
ucan: invocation,
|
||||
root
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// The round-ten P1, pinned where it bites. The middleware verifies the chain
|
||||
/// on the `X-Ucan` header of any signed request before the scope walk runs,
|
||||
/// so a depth bound that lived only in `chain_grants_push_to` protected
|
||||
/// nothing: a hand-built header reached the unbounded recursion first. A
|
||||
/// chain at `MAX_CHAIN_DEPTH` links validates; one link more is refused here,
|
||||
/// with 401 and a message that names depth, before any authorization runs.
|
||||
#[test]
|
||||
fn validate_ucan_chain_stops_at_the_depth_bound() {
|
||||
use gitlawb_core::ucan::MAX_CHAIN_DEPTH;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let node = Keypair::generate();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let resource = format!("gitlawb://repos/{}/myrepo", owner.did());
|
||||
let cap = || vec![Capability::new(&resource, caps::GIT_PUSH)];
|
||||
|
||||
// owner -> k1 -> ... -> signer: MAX_CHAIN_DEPTH - 1 links, all concrete,
|
||||
// all signed, all expiring. Only the length is in question.
|
||||
let mut signer = Keypair::generate();
|
||||
let mut cur = Ucan::issue(&owner, signer.did(), cap(), Some(hour)).unwrap();
|
||||
for _ in 2..MAX_CHAIN_DEPTH {
|
||||
let next = Keypair::generate();
|
||||
cur = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
signer = next;
|
||||
}
|
||||
|
||||
// Wrapped into an invocation for the node: exactly MAX_CHAIN_DEPTH links.
|
||||
let at_bound = Ucan::delegate(&signer, node.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let verified = validate_ucan_chain(&at_bound.encode().unwrap(), &node.did(), &signer.did())
|
||||
.expect("a chain at the bound must still validate");
|
||||
assert_eq!(verified.root, owner.did());
|
||||
|
||||
// One more link and it is past the bound.
|
||||
let next = Keypair::generate();
|
||||
let deeper = Ucan::delegate(&signer, next.did(), cap(), Some(hour), &cur).unwrap();
|
||||
let past_bound = Ucan::delegate(&next, node.did(), cap(), Some(hour), &deeper).unwrap();
|
||||
let (status, body) =
|
||||
match validate_ucan_chain(&past_bound.encode().unwrap(), &node.did(), &next.did()) {
|
||||
Ok(_) => panic!("a chain past the bound must be refused, not walked"),
|
||||
Err(refusal) => refusal,
|
||||
};
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let message = body.0["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("deeper than"),
|
||||
"the refusal must name depth as the reason: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_malformed_resource_uri() {
|
||||
let rec = repo(&owner_full(), "myrepo");
|
||||
for bad in [
|
||||
"",
|
||||
"myrepo",
|
||||
"https://repos/x/myrepo",
|
||||
"gitlawb://repos/myrepo",
|
||||
] {
|
||||
let v = verified(&owner_full(), vec![Capability::new(bad, caps::GIT_PUSH)]);
|
||||
assert!(!ucan_grants_push(&rec, &v), "{bad} must not grant push");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ 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;
|
||||
|
||||
@@ -297,7 +299,10 @@ pub struct Webhook {
|
||||
pub id: String,
|
||||
pub repo_id: String,
|
||||
pub url: String,
|
||||
pub secret: Option<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 events: Vec<String>,
|
||||
pub created_by_did: String,
|
||||
pub created_at: String,
|
||||
@@ -3878,7 +3883,7 @@ impl Db {
|
||||
.bind(&hook.id)
|
||||
.bind(&hook.repo_id)
|
||||
.bind(&hook.url)
|
||||
.bind(&hook.secret)
|
||||
.bind(hook.secret.as_ref().map(|s| s.as_str()))
|
||||
.bind(&events_json)
|
||||
.bind(&hook.created_by_did)
|
||||
.bind(&hook.created_at)
|
||||
@@ -3929,6 +3934,67 @@ 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 ──────────────────────────────────────────────────────────
|
||||
@@ -9164,7 +9230,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("secret"),
|
||||
secret: r.get::<Option<String>, _>("secret").map(WebhookSecret::new),
|
||||
events,
|
||||
created_by_did: r.get("created_by_did"),
|
||||
created_at: r.get("created_at"),
|
||||
|
||||
@@ -1439,6 +1439,7 @@ 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,
|
||||
@@ -1470,6 +1471,7 @@ 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,
|
||||
|
||||
@@ -126,15 +126,55 @@ fn decode_key(b64url: &str) -> Option<VerifyingKey> {
|
||||
VerifyingKey::from_bytes(&arr).ok()
|
||||
}
|
||||
|
||||
/// Upper bound on the iCaptcha `/v1/pubkey` response body. The document is a
|
||||
/// handful of keys at most; anything larger is refused rather than buffered
|
||||
/// into memory (the unbounded `.json()` read this replaces, #507).
|
||||
const KEY_MAX_BODY_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Read at most [`KEY_MAX_BODY_BYTES`] bytes of a key response. Returns `None`
|
||||
/// when the body exceeds the cap, so an oversized document is treated as absent
|
||||
/// exactly like a network error or a timeout (fail safe, never a partial key).
|
||||
async fn read_key_body(mut resp: reqwest::Response) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let take = (KEY_MAX_BODY_BYTES - buf.len()).min(chunk.len());
|
||||
buf.extend_from_slice(&chunk[..take]);
|
||||
if take < chunk.len() {
|
||||
return None; // exceeded the cap mid-chunk
|
||||
}
|
||||
}
|
||||
Ok(None) => return Some(buf),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_key(url: &str) -> Option<VerifyingKey> {
|
||||
let endpoint = format!("{}/v1/pubkey", url.trim_end_matches('/'));
|
||||
// Bounded request: a hung /v1/pubkey must never block node startup. On
|
||||
// timeout/error we return None and the gate stays inert (fail safe).
|
||||
//
|
||||
// No redirects and a capped body read: the key this fetch produces is the
|
||||
// TRUST ANCHOR for the whole proof gate, so a 3xx must not be allowed to move
|
||||
// where it comes from (anchor substitution / SSRF, matching the node's other
|
||||
// outbound clients — `build_http_client` in main.rs, the sync worker), and
|
||||
// the body is bounded rather than handed to `.json()`'s unbounded buffering.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.ok()?;
|
||||
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
|
||||
let resp = client.get(&endpoint).send().await.ok()?;
|
||||
// A non-2xx response body is not a key: with Policy::none() a 3xx is
|
||||
// surfaced rather than followed, but its own body must not be parsed
|
||||
// either, or the redirect would move the anchor after all.
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = read_key_body(resp).await?;
|
||||
let jwks: Jwks = serde_json::from_slice(&body).ok()?;
|
||||
decode_key(&jwks.keys.first()?.x)
|
||||
}
|
||||
|
||||
@@ -647,4 +687,129 @@ mod tests {
|
||||
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
|
||||
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
|
||||
}
|
||||
|
||||
/// `{"keys":[{"x":"<base64url ed25519 point>"}]}` for the live `PUBKEY_X`.
|
||||
fn jwks_body() -> Vec<u8> {
|
||||
format!(r#"{{"keys":[{{"x":"{PUBKEY_X}"}}]}}"#).into_bytes()
|
||||
}
|
||||
|
||||
/// The no-redirect / bounded-body client still parses a valid JWKS, so the
|
||||
/// hardening did not break the happy path the gate depends on at startup.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_parses_a_valid_jwks() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let key = fetch_key(&server.url()).await.expect("valid JWKS parses");
|
||||
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
|
||||
}
|
||||
|
||||
/// A 302 must not move the trust anchor. The redirect target serves a REAL
|
||||
/// key, so following the redirect would make `fetch_key` adopt it; instead
|
||||
/// `Policy::none()` leaves the 302's (non-JWKS) body as the answer → None.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_does_not_follow_a_redirect() {
|
||||
let mut target = mockito::Server::new_async().await;
|
||||
let _t = target
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.expect(0)
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut redirector = mockito::Server::new_async().await;
|
||||
// Location points at the target's /v1/pubkey, so a client that DID
|
||||
// follow the redirect would fetch the real key and return Some: the
|
||||
// assertion below only holds while Policy::none() is in place.
|
||||
let _r = redirector
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(302)
|
||||
.with_header("Location", &format!("{}/v1/pubkey", target.url()))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&redirector.url()).await.is_none(),
|
||||
"a redirect must not be followed to a host serving a valid key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body past the cap is refused rather than buffered, so a broken
|
||||
/// endpoint cannot make the node materialize an arbitrarily large document.
|
||||
/// The body is a VALID JWKS (`jwks_body()`) padded with JSON whitespace past
|
||||
/// the cap, so an unbounded read would still parse it; this test only passes
|
||||
/// because the byte cap refuses it.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_rejects_an_oversized_body() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut oversized = jwks_body();
|
||||
oversized.resize(KEY_MAX_BODY_BYTES + 1, b' ');
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(oversized)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&server.url()).await.is_none(),
|
||||
"an oversized body must be refused even when it is a valid JWKS"
|
||||
);
|
||||
}
|
||||
|
||||
/// A body of exactly the cap still parses: the contract refuses bodies that
|
||||
/// EXCEED it, not bodies that reach it (the padded JWKS is exactly
|
||||
/// KEY_MAX_BODY_BYTES bytes of otherwise-valid JSON).
|
||||
#[tokio::test]
|
||||
async fn fetch_key_accepts_a_body_at_the_cap() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mut at_cap = jwks_body();
|
||||
at_cap.resize(KEY_MAX_BODY_BYTES, b' ');
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(200)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(at_cap)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let key = fetch_key(&server.url()).await.expect("at-cap JWKS parses");
|
||||
assert_eq!(key, decode_key(PUBKEY_X).unwrap());
|
||||
}
|
||||
|
||||
/// A non-2xx response never supplies the anchor, even when its body is a
|
||||
/// perfectly valid JWKS: the redirect pin only stops the hop, and the
|
||||
/// status gate is what stops the 3xx's own body from being adopted.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_rejects_a_non_success_status() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", "/v1/pubkey")
|
||||
.with_status(500)
|
||||
.with_header("Content-Type", "application/json")
|
||||
.with_body(jwks_body())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
fetch_key(&server.url()).await.is_none(),
|
||||
"a non-2xx body must not be adopted as the anchor even when it parses"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unreachable endpoint is the same fail-safe None as every other fetch
|
||||
/// error: the gate stays inert rather than the node failing to start.
|
||||
#[tokio::test]
|
||||
async fn fetch_key_returns_none_when_the_endpoint_is_unreachable() {
|
||||
assert!(fetch_key("http://127.0.0.1:1").await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod sync;
|
||||
mod test_support;
|
||||
mod upstream_mirror;
|
||||
mod visibility;
|
||||
mod webhook_secret;
|
||||
mod webhooks;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
@@ -99,6 +100,7 @@ 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
|
||||
@@ -481,6 +483,7 @@ 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,
|
||||
@@ -569,6 +572,23 @@ 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");
|
||||
}
|
||||
|
||||
@@ -342,9 +342,11 @@ pub fn record_sync_processed(status: &str) {
|
||||
}
|
||||
|
||||
/// Record one webhook delivery attempt outcome. `result` ∈
|
||||
/// {ok, http_error, network_error, skipped, invalid_url}.
|
||||
/// {ok, http_error, network_error, skipped, invalid_url, secret_unopenable}.
|
||||
/// `invalid_url` is a policy skip: the stored hook URL failed the public-URL
|
||||
/// check at delivery time and the POST was never sent.
|
||||
/// 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.
|
||||
pub fn record_webhook_delivery(result: &str) {
|
||||
if let Some(c) = WEBHOOK_DELIVERIES.get() {
|
||||
c.with_label_values(&[result]).inc();
|
||||
|
||||
@@ -21,6 +21,19 @@ 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,
|
||||
@@ -173,6 +186,11 @@ 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
|
||||
@@ -431,20 +449,48 @@ 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 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 mut extract = HmacSha256::new_from_slice(salt).expect("HMAC takes a key of any size");
|
||||
extract.update(ikm);
|
||||
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(SCAN_TOKEN_KEY_INFO);
|
||||
expand.update(info);
|
||||
expand.update(&[0x01]);
|
||||
expand.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
@@ -578,7 +578,18 @@ async fn replicate_encrypted_blobs(
|
||||
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate");
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return,
|
||||
// A peer that predates the replicate route answers 404: expected and
|
||||
// quiet. Anything else is the route existing and refusing or failing,
|
||||
// which should be observable rather than read as route-absent.
|
||||
Ok(r) if r.status().as_u16() == 404 => return,
|
||||
Ok(r) => {
|
||||
tracing::warn!(repo = %repo, status = %r.status(), "encrypted-blobs replicate refused; skipping");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo = %repo, error = %e, "encrypted-blobs replicate request failed; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parsed: ReplicateResponse = match resp.json().await {
|
||||
Ok(p) => p,
|
||||
|
||||
@@ -83,6 +83,7 @@ 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);
|
||||
@@ -118,6 +119,7 @@ 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)),
|
||||
@@ -4205,7 +4207,9 @@ mod tests {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
repo_id: repo.id.clone(),
|
||||
url: secret_url.to_string(),
|
||||
secret: Some("topsecret".to_string()),
|
||||
secret: Some(crate::webhook_secret::WebhookSecret::new(
|
||||
"topsecret".to_string(),
|
||||
)),
|
||||
events: vec!["*".to_string()],
|
||||
created_by_did: owner.to_string(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
@@ -5969,6 +5973,566 @@ mod tests {
|
||||
authorization"
|
||||
);
|
||||
}
|
||||
/// Delegated push, end to end through both auth layers.
|
||||
///
|
||||
/// A non-owner presenting an invocation whose chain roots at the repo owner
|
||||
/// clears the owner-push gate; the same signer without one, and with one that
|
||||
/// names a different repository, are both refused. This is the regression the
|
||||
/// owner-push default introduced: a CI or delegated key holding a valid
|
||||
/// `git/push` capability was refused exactly like a stranger.
|
||||
///
|
||||
/// Status codes are the discriminators. 500 means the request passed
|
||||
/// `require_signature` (not 401), passed `require_ucan_chain` (not 401), and
|
||||
/// cleared the owner gate (not 403), then reached git on a repo with no disk
|
||||
/// backing — the same shape `git_upload_pack_post_is_read_gated_on_private_repo`
|
||||
/// relies on. A bare `!= 403` would let a 401 regression pass.
|
||||
///
|
||||
/// Not `#[cfg(unix)]`: no fake-git shim is involved, only HTTP and the gate.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_clears_the_owner_gate(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
// Explicit rather than relying on the shipped default, so this test states
|
||||
// the configuration it is about.
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_repo(&owner_did, "deleg"))
|
||||
.await
|
||||
.expect("seed repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
|
||||
let path = format!("/{short}/deleg.git/git-receive-pack");
|
||||
let body = b"0000".to_vec();
|
||||
|
||||
// owner -> agent delegation, then agent -> node invocation carrying it.
|
||||
// Both links carry a finite expiry: a write capability that never lapses is
|
||||
// refused, since there is no revocation path to withdraw a leaked one.
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let invocation_with_exp = |resource: String, exp: Option<chrono::DateTime<chrono::Utc>>| {
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(resource, caps::GIT_PUSH)],
|
||||
exp,
|
||||
)
|
||||
.expect("issue delegation");
|
||||
Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
exp,
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap invocation")
|
||||
.encode()
|
||||
.expect("encode invocation")
|
||||
};
|
||||
let invocation_for = |resource: String| invocation_with_exp(resource, Some(hour));
|
||||
|
||||
let signed_push = |ucan: Option<String>| {
|
||||
let signed = sign_request(&agent, "POST", &path, &body);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::from(body.clone())).expect("request")
|
||||
};
|
||||
|
||||
// 1. Valid delegation for THIS repo: clears the gate.
|
||||
let resp = router()
|
||||
.oneshot(signed_push(Some(invocation_for(format!(
|
||||
"gitlawb://repos/{owner_did}/deleg"
|
||||
)))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"an owner-rooted git/push delegation must clear the owner gate and reach git"
|
||||
);
|
||||
|
||||
// 2. Delegation naming a DIFFERENT repo: refused.
|
||||
let other = router()
|
||||
.oneshot(signed_push(Some(invocation_for(format!(
|
||||
"gitlawb://repos/{owner_did}/someotherrepo"
|
||||
)))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
other.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation for another repository must not authorize this push"
|
||||
);
|
||||
let other_body = axum::body::to_bytes(other.into_body(), 4096).await.unwrap();
|
||||
|
||||
// 3. No delegation at all: refused, with a byte-identical body. A caller
|
||||
// must not be able to tell a non-applicable delegation from none, or the
|
||||
// denial becomes an oracle for which capabilities exist.
|
||||
let none = router().oneshot(signed_push(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
none.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a non-owner with no delegation must still be refused"
|
||||
);
|
||||
let none_body = axum::body::to_bytes(none.into_body(), 4096).await.unwrap();
|
||||
assert_eq!(
|
||||
other_body, none_body,
|
||||
"an inapplicable delegation and no delegation must be indistinguishable"
|
||||
);
|
||||
|
||||
// 4. A delegation that never expires: refused, however otherwise valid.
|
||||
// Owner-rooted, right repo, right action — but with no revocation path an
|
||||
// unbounded grant cannot be withdrawn once the token leaks.
|
||||
let perpetual = router()
|
||||
.oneshot(signed_push(Some(invocation_with_exp(
|
||||
format!("gitlawb://repos/{owner_did}/deleg"),
|
||||
None,
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
perpetual.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation with no expiry must not authorize a push"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two-gate contract on a PRIVATE repository, end to end.
|
||||
///
|
||||
/// A `git push` crosses two independent gates. Gate 1 is read visibility on
|
||||
/// the `info/refs` advertisement (both services): the owner, anyone on a public
|
||||
/// repository, or a caller a rule names in `reader_dids`. Gate 2 is push
|
||||
/// authorization on the `git-receive-pack` POST, the owner gate a delegation
|
||||
/// clears. A UCAN speaks only to gate 2 and grants no read, so a delegate on a
|
||||
/// private repository needs BOTH a push delegation AND a reader rule. That
|
||||
/// composition — not a POST defect — is what "delegated push does not work on
|
||||
/// my private repo" turns out to be; the sibling test above runs on a public
|
||||
/// repository and never meets gate 1.
|
||||
///
|
||||
/// Status codes discriminate as in the sibling: 404 is gate 1 withholding the
|
||||
/// advertisement, 403 is gate 2 refusing the push, 500 is a request that cleared
|
||||
/// every gate and reached git on a repo with no disk backing.
|
||||
///
|
||||
/// Each gate is mounted under the middleware production gives it (`server.rs`):
|
||||
/// `optional_signature` alone on `info/refs`, `require_signature` plus
|
||||
/// `require_ucan_chain` on the receive-pack POST.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_on_a_private_repo_needs_read_visibility_too(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
let mut repo = seed_repo(&owner_did, "priv-deleg");
|
||||
repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
|
||||
let advertisement = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/info/refs",
|
||||
axum::routing::get(crate::api::repos::git_info_refs),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let receive_pack = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
};
|
||||
|
||||
// A delegation that is valid in every respect the push gate checks.
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/priv-deleg"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
Some(hour),
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap invocation")
|
||||
.encode()
|
||||
.expect("encode invocation");
|
||||
|
||||
let advert_path = format!("/{short}/priv-deleg.git/info/refs?service=git-receive-pack");
|
||||
let signed_advert = |ucan: Option<&str>| {
|
||||
let signed = sign_request(&agent, "GET", &advert_path, b"");
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&advert_path)
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::empty()).expect("request")
|
||||
};
|
||||
let push_path = format!("/{short}/priv-deleg.git/git-receive-pack");
|
||||
let push_body = b"0000".to_vec();
|
||||
let signed_push = |ucan: Option<&str>| {
|
||||
let signed = sign_request(&agent, "POST", &push_path, &push_body);
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&push_path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature);
|
||||
if let Some(token) = ucan {
|
||||
req = req.header("x-ucan", token);
|
||||
}
|
||||
req.body(Body::from(push_body.clone())).expect("request")
|
||||
};
|
||||
|
||||
// 1. Delegation, no reader rule. Gate 1 withholds the advertisement even
|
||||
// though the delegate presents the token there too: a UCAN grants no read.
|
||||
let resp = advertisement()
|
||||
.oneshot(signed_advert(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a push delegation must not open the advertisement of a private repo"
|
||||
);
|
||||
// Gate 2, taken on its own, is satisfied by the same delegation: the two
|
||||
// gates are independent, which is exactly why both must be arranged.
|
||||
let resp = receive_pack()
|
||||
.oneshot(signed_push(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a non-reader delegate is refused on the POST itself, not just the advertisement: the receive-pack route runs the same visibility gate because a caller can skip info/refs entirely"
|
||||
);
|
||||
|
||||
// 2. The owner makes the agent a reader of the whole repository — what
|
||||
// `gl visibility set / --repo <repo> --readers <agent>` does.
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(
|
||||
&repo.id,
|
||||
"/",
|
||||
crate::db::VisibilityMode::B,
|
||||
&[agent.did().to_string()],
|
||||
&owner_did,
|
||||
)
|
||||
.await
|
||||
.expect("reader rule");
|
||||
let resp = advertisement()
|
||||
.oneshot(signed_advert(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a reader clears the advertisement gate and reaches git"
|
||||
);
|
||||
let resp = receive_pack()
|
||||
.oneshot(signed_push(Some(&invocation)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"with both gates arranged the push reaches git"
|
||||
);
|
||||
|
||||
// 3. Reader rule, no delegation: reading is not pushing.
|
||||
let resp = advertisement().oneshot(signed_advert(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the reader rule alone still opens the advertisement"
|
||||
);
|
||||
let resp = receive_pack().oneshot(signed_push(None)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a reader with no delegation must still be refused on the push"
|
||||
);
|
||||
}
|
||||
|
||||
/// A valid delegation clears the owner gate but is still refused on a branch
|
||||
/// the owner has explicitly protected.
|
||||
///
|
||||
/// Two predicates deliberately disagree: the owner gate accepts a delegate, the
|
||||
/// branch-protection loop is owner-only. A protected branch is the owner's
|
||||
/// marker that even routine writes should stop, so a `git/push` delegation must
|
||||
/// not silently override it — otherwise issuing any capability would weaken
|
||||
/// every protection the owner had already set.
|
||||
///
|
||||
/// The 403 body is the discriminator: it must name the branch, proving the
|
||||
/// request reached branch protection rather than being turned away by the owner
|
||||
/// gate for lacking a delegation.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_is_still_refused_on_a_protected_branch(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
const ZERO: &str = "0000000000000000000000000000000000000000";
|
||||
let new_sha = "1111111111111111111111111111111111111111";
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
let rec = seed_repo(&owner_did, "protrepo");
|
||||
state.db.create_repo(&rec).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.protect_branch(&rec.id, "main", &owner_did)
|
||||
.await
|
||||
.expect("protect main");
|
||||
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/protrepo"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
Some(hour),
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap")
|
||||
.encode()
|
||||
.expect("encode");
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let path = format!("/{short}/protrepo.git/git-receive-pack");
|
||||
let line = format!("{ZERO} {new_sha} refs/heads/main");
|
||||
let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes();
|
||||
|
||||
let signed = sign_request(&agent, "POST", &path, &body);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.header("x-ucan", invocation)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a delegation must not override branch protection"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains("protected"),
|
||||
"the refusal must come from branch protection, not the owner gate; got {text}"
|
||||
);
|
||||
}
|
||||
/// A delegated push whose branch-protection lookup FAILS must be refused,
|
||||
/// not silently allowed: the lookup is fail-closed for non-owners, so a
|
||||
/// database error on a protected branch cannot be pushed through.
|
||||
#[sqlx::test]
|
||||
async fn delegated_push_fails_closed_when_protection_lookup_errors(pool: PgPool) {
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
use gitlawb_core::ucan::{caps, Capability, Ucan};
|
||||
use std::sync::Arc;
|
||||
|
||||
const ZERO: &str = "0000000000000000000000000000000000000000";
|
||||
let new_sha = "1111111111111111111111111111111111111111";
|
||||
|
||||
let owner = Keypair::generate();
|
||||
let agent = Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
|
||||
let mut state = test_state(pool).await;
|
||||
let mut cfg = (*state.config).clone();
|
||||
cfg.enforce_owner_push = true;
|
||||
state.config = Arc::new(cfg);
|
||||
|
||||
let rec = seed_repo(&owner_did, "protrepo");
|
||||
state.db.create_repo(&rec).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.protect_branch(&rec.id, "main", &owner_did)
|
||||
.await
|
||||
.expect("protect main");
|
||||
|
||||
// Poison the lookup: is_branch_protected SELECTs this table, so every
|
||||
// call after the drop returns Err.
|
||||
sqlx::query("DROP TABLE protected_branches")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.expect("drop protected_branches");
|
||||
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let delegation = Ucan::issue(
|
||||
&owner,
|
||||
agent.did(),
|
||||
vec![Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/protrepo"),
|
||||
caps::GIT_PUSH,
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.expect("issue delegation");
|
||||
let invocation = Ucan::delegate(
|
||||
&agent,
|
||||
state.node_did.clone(),
|
||||
delegation.payload.att.clone(),
|
||||
Some(hour),
|
||||
&delegation,
|
||||
)
|
||||
.expect("wrap")
|
||||
.encode()
|
||||
.expect("encode");
|
||||
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/{owner}/{repo}/git-receive-pack",
|
||||
axum::routing::post(crate::api::repos::git_receive_pack),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_ucan_chain,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::require_signature,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let path = format!("/{short}/protrepo.git/git-receive-pack");
|
||||
let line = format!("{ZERO} {new_sha} refs/heads/main");
|
||||
let body = format!("{:04x}{}0000", line.len() + 4, line).into_bytes();
|
||||
|
||||
let signed = sign_request(&agent, "POST", &path, &body);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&path)
|
||||
.header("content-type", "application/x-git-receive-pack-request")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.header("x-ucan", invocation)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert!(
|
||||
!resp.status().is_success(),
|
||||
"a failed protection lookup must refuse the push, got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after
|
||||
/// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack
|
||||
@@ -18723,6 +19287,366 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Public-but-quarantined repos must not expose encrypted blob indexes
|
||||
/// (discovery or replicate). Previously these handlers used
|
||||
/// `visibility_check` alone and skipped the quarantine short-circuit in
|
||||
/// `authorize_repo_read`.
|
||||
#[sqlx::test]
|
||||
async fn encrypted_blobs_quarantined_repo_opaque_404(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zENCQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let short = owner.split(':').next_back().unwrap();
|
||||
let mut repo = seed_private_repo(owner, "enc-quar");
|
||||
repo.is_public = true;
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.record_encrypted_blob(&repo_id, "deadbeef", "bafybeiquarantinedcid", "")
|
||||
.await
|
||||
.unwrap();
|
||||
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/deadbeef",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"quarantined public repo must 404 on {path}"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("bafybeiquarantinedcid") && !text.contains("deadbeef"),
|
||||
"blob index must not leak on quarantine 404 for {path}: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// Owner (full did:key and bare key) must also 404 — quarantine is not a
|
||||
// visibility deny that the owner short-circuit can bypass.
|
||||
for caller in [owner, short] {
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/deadbeef",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_request_as(caller, Method::GET, &path, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} must not read quarantined {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Control: clear quarantine → all three discovery surfaces admit again.
|
||||
// `encrypted-blob/{oid}` may 500 without a live IPFS node after the gate
|
||||
// opens; assert it is not the quarantine 404.
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-quar/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("bafybeiquarantinedcid"),
|
||||
"released repo must expose blob index on {path}: {text}"
|
||||
);
|
||||
}
|
||||
let get_path = format!("/api/v1/repos/{short}/enc-quar/encrypted-blob/deadbeef");
|
||||
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::OK || resp.status().is_server_error(),
|
||||
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror-admission path (`upsert_mirror_repo` + slash-form id) must also
|
||||
/// opaque-404 encrypted discovery while quarantined.
|
||||
#[sqlx::test]
|
||||
async fn encrypted_blobs_quarantined_mirror_admission_opaque_404(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let short = "z6MkEncMirrorAdmitAAAAAAAAAAAAAAAAAAAA";
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(short, "enc-mirror", "/tmp/enc-mirror", None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
let repo_id = format!("{short}/enc-mirror");
|
||||
state
|
||||
.db
|
||||
.record_encrypted_blob(&repo_id, "aabbccdd", "bafybeimirrorcid", "")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
for suffix in [
|
||||
"encrypted-blobs",
|
||||
"encrypted-blobs/replicate",
|
||||
"encrypted-blob/aabbccdd",
|
||||
] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anon must 404 quarantined mirror {path}"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("bafybeimirrorcid") && !text.contains("aabbccdd"),
|
||||
"blob index must not leak on quarantine 404 for {path}: {text}"
|
||||
);
|
||||
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_request_as(short, Method::GET, &path, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"bare-key mirror owner must not read quarantined {path}"
|
||||
);
|
||||
}
|
||||
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
for suffix in ["encrypted-blobs", "encrypted-blobs/replicate"] {
|
||||
let path = format!("/api/v1/repos/{short}/enc-mirror/{suffix}");
|
||||
let resp = router.clone().oneshot(anon_get(&path)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "released must admit {path}");
|
||||
}
|
||||
let get_path = format!("/api/v1/repos/{short}/enc-mirror/encrypted-blob/aabbccdd");
|
||||
let resp = router.oneshot(anon_get(&get_path)).await.unwrap();
|
||||
assert!(
|
||||
resp.status() == StatusCode::OK || resp.status().is_server_error(),
|
||||
"released get must admit (OK) or reach the backend (5xx); a 4xx means the denial path still fires: {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
/// `withheld-paths` and `list_visibility` must share the quarantine gate.
|
||||
#[sqlx::test]
|
||||
async fn withheld_paths_and_list_visibility_quarantine_opaque(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zVISQUAROWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let short = owner.split(':').next_back().unwrap();
|
||||
let mut repo = seed_private_repo(owner, "vis-quar");
|
||||
repo.is_public = true;
|
||||
let repo_id = repo.id.clone();
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(&repo_id, "/secret/**", VisibilityMode::B, &[], owner)
|
||||
.await
|
||||
.unwrap();
|
||||
state.db.set_repo_quarantine(&repo_id, true).await.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
let withheld = format!("/api/v1/repos/{short}/vis-quar/withheld-paths");
|
||||
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anon withheld-paths must 404 while quarantined"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
!text.contains("/secret"),
|
||||
"withheld layout must not leak: {text}"
|
||||
);
|
||||
|
||||
for caller in [owner, short] {
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(crate::api::visibility::withheld_paths),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(
|
||||
caller,
|
||||
Method::GET,
|
||||
&withheld,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} withheld-paths must 404 while quarantined"
|
||||
);
|
||||
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::get(crate::api::visibility::list_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::GET, &vis, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} list_visibility must 404 while quarantined"
|
||||
);
|
||||
|
||||
// PUT/DELETE must also opaque-404 while quarantined (no rule mutation).
|
||||
let put_body = Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#);
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::put(crate::api::visibility::set_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::PUT, &vis, put_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} set_visibility must 404 while quarantined"
|
||||
);
|
||||
|
||||
let del_body = Body::from(r#"{"path_glob":"/secret/**"}"#);
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::delete(crate::api::visibility::remove_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(caller, Method::DELETE, &vis, del_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"owner form {caller} remove_visibility must 404 while quarantined"
|
||||
);
|
||||
}
|
||||
|
||||
// A signed non-owner must get 404 too, never 403: this leg is what pins
|
||||
// the quarantine-before-owner ordering. If require_owner were hoisted
|
||||
// above authorize_repo_read the same caller would read Forbidden, an
|
||||
// existence oracle on a hidden repo.
|
||||
let stranger = "did:key:zSTRANGERQUARAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
for (method, path, body) in [
|
||||
(Method::GET, withheld.as_str(), Body::empty()),
|
||||
(Method::GET, vis.as_str(), Body::empty()),
|
||||
(
|
||||
Method::PUT,
|
||||
vis.as_str(),
|
||||
Body::from(r#"{"path_glob":"/extra/**","reader_dids":[]}"#),
|
||||
),
|
||||
(
|
||||
Method::DELETE,
|
||||
vis.as_str(),
|
||||
Body::from(r#"{"path_glob":"/secret/**"}"#),
|
||||
),
|
||||
] {
|
||||
let handler: axum::routing::MethodRouter<crate::state::AppState> = match method.clone()
|
||||
{
|
||||
Method::GET => axum::routing::get(crate::api::visibility::list_visibility),
|
||||
Method::PUT => axum::routing::put(crate::api::visibility::set_visibility),
|
||||
_ => axum::routing::delete(crate::api::visibility::remove_visibility),
|
||||
};
|
||||
let router = if path == withheld.as_str() {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/withheld-paths",
|
||||
axum::routing::get(crate::api::visibility::withheld_paths),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
crate::auth::SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
|
||||
crate::auth::optional_signature,
|
||||
))
|
||||
.with_state(state.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
.route("/api/v1/repos/{owner}/{repo}/visibility", handler)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let resp = router
|
||||
.oneshot(signed_request_as(stranger, method.clone(), path, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"non-owner {method} {path} must 404 (not 403) while quarantined"
|
||||
);
|
||||
}
|
||||
|
||||
state.db.set_repo_quarantine(&repo_id, false).await.unwrap();
|
||||
let resp = router.clone().oneshot(anon_get(&withheld)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("/secret"),
|
||||
"released withheld-paths must return globs: {text}"
|
||||
);
|
||||
|
||||
// Same release control for list_visibility (owner-gated after gate opens).
|
||||
let vis = format!("/api/v1/repos/{short}/vis-quar/visibility");
|
||||
let resp = Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/visibility",
|
||||
axum::routing::get(crate::api::visibility::list_visibility),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.oneshot(signed_request_as(owner, Method::GET, &vis, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"released list_visibility must admit owner"
|
||||
);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("/secret"),
|
||||
"released list_visibility must return rules: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn repo_gate_public_repo_anon_read_admitted(pool: PgPool) {
|
||||
struct DirGuard(std::path::PathBuf);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,50 @@ 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, repo_id, event, payload, None, None);
|
||||
fire_event_occurrence(
|
||||
db,
|
||||
http_client,
|
||||
secret_key,
|
||||
repo_id,
|
||||
event,
|
||||
payload,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Occurrence-keyed variant: at most one delivery per
|
||||
@@ -50,9 +85,11 @@ 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,
|
||||
@@ -67,6 +104,7 @@ pub fn fire_event_occurrence(
|
||||
fire_event_async_occurrence(
|
||||
db,
|
||||
http_client,
|
||||
secret_key,
|
||||
&repo_id,
|
||||
&event,
|
||||
payload,
|
||||
@@ -83,6 +121,7 @@ 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,
|
||||
@@ -98,6 +137,7 @@ pub fn fire_event_occurrence_with_claimed_hooks(
|
||||
fire_event_async_occurrence(
|
||||
db,
|
||||
http_client,
|
||||
secret_key,
|
||||
&repo_id,
|
||||
&event,
|
||||
payload,
|
||||
@@ -163,6 +203,7 @@ 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,
|
||||
@@ -224,6 +265,7 @@ 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
|
||||
@@ -239,7 +281,28 @@ async fn fire_event_async_occurrence(
|
||||
}
|
||||
|
||||
let delivery_for_mark = delivery_id.clone();
|
||||
let signature = hook.secret.as_deref().map(|s| sign_payload(s, &bytes));
|
||||
// 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 mut req = client
|
||||
.post(&hook.url)
|
||||
@@ -284,3 +347,60 @@ 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -267,7 +267,31 @@ async fn fetch_withheld(node: &str, owner: &str, name: &str) -> Result<(Vec<Stri
|
||||
};
|
||||
let resp = match resp {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
|
||||
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => {
|
||||
// Two 404s share this status and must be told apart before the
|
||||
// legacy empty-glob fallback is taken: a route miss on a node that
|
||||
// predates withheld-paths has an empty body, while a current node
|
||||
// returns the JSON AppError body for absent/denied/quarantined
|
||||
// repos. The JSON body proves the route exists, so the repo is
|
||||
// unavailable to this caller and cloning cannot proceed.
|
||||
if r.status().as_u16() == 404 {
|
||||
let status = r.status();
|
||||
let body = crate::http::read_body_capped(r, 4096).await;
|
||||
if body.read_failed {
|
||||
// The read died part-way: we cannot classify the denial,
|
||||
// and a failed read must not collapse into the legacy
|
||||
// route-miss arm and start a clone.
|
||||
bail!("withheld-paths 404 body from this node could not be read");
|
||||
}
|
||||
if !body.text.trim().is_empty() {
|
||||
bail!(
|
||||
"repository {owner}/{name} is not available on this node ({status}): {}",
|
||||
sanitize_node_msg(&body.text)
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
|
||||
Err(err) => return Err(err).context("fetching withheld paths"),
|
||||
};
|
||||
@@ -327,7 +351,12 @@ async fn recover_encrypted_blobs(
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return Ok(vec![]),
|
||||
// Discovery failure is not an empty inventory: a denied, quarantined,
|
||||
// erroring, or unreachable node must surface so the caller can warn
|
||||
// and still run the Arweave fallback rather than report a clean clone
|
||||
// with authorized blobs silently missing.
|
||||
Ok(r) => bail!("encrypted-blobs inventory unavailable: {}", r.status()),
|
||||
Err(err) => return Err(err).context("fetching encrypted-blobs inventory"),
|
||||
};
|
||||
let body: EncryptedBlobsResponse = resp.json().await.context("parsing encrypted-blobs")?;
|
||||
if body.blobs.is_empty() {
|
||||
|
||||
+98
-37
@@ -24,7 +24,7 @@ pub struct DoctorArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -69,22 +69,63 @@ impl Check {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the config dir and key path without aborting on a refused resolver.
|
||||
/// The whole point of doctor is to explain a broken setup, so a `GITLAWB_KEY`
|
||||
/// or `--dir` the resolvers reject becomes `Check::fail` rows for the checks
|
||||
/// that depend on them, while node/git checks still run.
|
||||
fn resolve_config_paths(
|
||||
args_dir: Option<PathBuf>,
|
||||
) -> (Option<PathBuf>, Option<PathBuf>, Vec<Check>) {
|
||||
let dir = crate::identity::gitlawb_dir(args_dir.clone());
|
||||
let mut checks = Vec::new();
|
||||
let dir = match dir {
|
||||
Ok(d) => Some(d),
|
||||
Err(e) => {
|
||||
checks.push(Check::fail(
|
||||
"identity",
|
||||
format!("could not resolve config dir: {e}"),
|
||||
"fix GITLAWB_KEY or --dir",
|
||||
));
|
||||
checks.push(Check::fail(
|
||||
"registration",
|
||||
"skipped: config dir unresolved",
|
||||
"fix GITLAWB_KEY or --dir",
|
||||
));
|
||||
None
|
||||
}
|
||||
};
|
||||
let pem_path = match crate::identity::key_path_for(args_dir.as_deref()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
if dir.is_some() {
|
||||
checks.push(Check::fail(
|
||||
"identity",
|
||||
format!("could not resolve key path: {e}"),
|
||||
"fix GITLAWB_KEY or --dir",
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
(dir, pem_path, checks)
|
||||
}
|
||||
|
||||
pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
println!("gl doctor — checking your Twigpine setup");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
|
||||
let mut checks = Vec::new();
|
||||
// The same resolver every other command uses. `doctor` reporting on
|
||||
// `~/.gitlawb` while `gl register` writes to the parent of `GITLAWB_KEY` would
|
||||
// make the one command whose job is to explain a broken setup the one that
|
||||
// misreports it.
|
||||
let args_dir = args.dir.clone();
|
||||
let (dir, pem_path, resolver_rows) = resolve_config_paths(args_dir.clone());
|
||||
let mut checks = resolver_rows;
|
||||
|
||||
// ── 1. Identity ───────────────────────────────────────────────────────
|
||||
let pem_path = dir.join("identity.pem");
|
||||
if pem_path.exists() {
|
||||
match std::fs::read_to_string(&pem_path)
|
||||
if pem_path.as_ref().is_some_and(|p| p.exists()) {
|
||||
let pem_path = pem_path.as_ref().unwrap();
|
||||
match std::fs::read_to_string(pem_path)
|
||||
.ok()
|
||||
.and_then(|pem| gitlawb_core::identity::Keypair::from_pem(&pem).ok())
|
||||
{
|
||||
@@ -101,7 +142,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if let Some(pem_path) = &pem_path {
|
||||
checks.push(Check::fail(
|
||||
"identity",
|
||||
format!("not found at {}", pem_path.display()),
|
||||
@@ -110,33 +151,35 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
// ── 2. Registration ───────────────────────────────────────────────────
|
||||
let ucan_path = dir.join("ucan.json");
|
||||
if ucan_path.exists() {
|
||||
match std::fs::read_to_string(&ucan_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
{
|
||||
Some(v) => {
|
||||
let node = v["node"].as_str().unwrap_or("unknown");
|
||||
checks.push(Check::pass(
|
||||
"registration",
|
||||
format!("registered with {node}"),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
checks.push(Check::fail(
|
||||
"registration",
|
||||
"ucan.json exists but is malformed",
|
||||
"gl register",
|
||||
));
|
||||
if let Some(dir) = &dir {
|
||||
let ucan_path = dir.join("ucan.json");
|
||||
if ucan_path.exists() {
|
||||
match std::fs::read_to_string(&ucan_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
{
|
||||
Some(v) => {
|
||||
let node = v["node"].as_str().unwrap_or("unknown");
|
||||
checks.push(Check::pass(
|
||||
"registration",
|
||||
format!("registered with {node}"),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
checks.push(Check::fail(
|
||||
"registration",
|
||||
"ucan.json exists but is malformed",
|
||||
"gl register",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checks.push(Check::fail(
|
||||
"registration",
|
||||
"not registered with any node",
|
||||
"gl register",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
checks.push(Check::fail(
|
||||
"registration",
|
||||
"not registered with any node",
|
||||
"gl register",
|
||||
));
|
||||
}
|
||||
|
||||
// ── 3. GITLAWB_NODE env var ───────────────────────────────────────────
|
||||
@@ -605,6 +648,24 @@ mod tests {
|
||||
assert!(check_shell_alias_shadowing(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_resolver_becomes_check_rows_not_abort() {
|
||||
// A relative GITLAWB_KEY makes both resolvers Err; doctor must turn
|
||||
// that into fail rows for the dependent checks and keep going, not
|
||||
// exit before the node/git checks ever run.
|
||||
crate::identity::test_env::with_key(Some("relative/path"), || {
|
||||
let (dir, pem_path, rows) = resolve_config_paths(None);
|
||||
assert!(dir.is_none() && pem_path.is_none());
|
||||
let fails: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|c| matches!(c.state, CheckState::Fail))
|
||||
.map(|c| c.label)
|
||||
.collect();
|
||||
assert!(fails.contains(&"identity"), "rows: {fails:?}");
|
||||
assert!(fails.contains(&"registration"), "rows: {fails:?}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_newer_minor_bump() {
|
||||
assert!(is_newer("0.2.0", "0.1.0"));
|
||||
|
||||
@@ -324,8 +324,8 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
|
||||
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
|
||||
/// `char::is_control` does not cover — they can reorder the displayed line).
|
||||
pub(crate) fn sanitize_node_msg(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control() && !gitlawb_core::sanitize::is_bidi_format(*c))
|
||||
gitlawb_core::sanitize::strip_terminal_controls(s)
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect()
|
||||
}
|
||||
|
||||
+287
-25
@@ -9,7 +9,8 @@ use std::path::{Path, PathBuf};
|
||||
pub enum IdentityCmd {
|
||||
/// Generate a new Ed25519 keypair and DID
|
||||
New {
|
||||
/// Output directory for key files (default: ~/.gitlawb)
|
||||
/// Output directory for key files
|
||||
/// (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Overwrite existing keys if present
|
||||
@@ -63,16 +64,51 @@ pub async fn run(cmd: IdentityCmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
/// Resolve the identity directory, honouring an explicit override.
|
||||
/// Public so sibling commands (`gl ucan import`, `gl doctor`) look in the same
|
||||
/// place the identity itself lives.
|
||||
///
|
||||
/// Without an override this is [`gitlawb_core::identity_path::identity_dir`] — the
|
||||
/// parent of `GITLAWB_KEY`, else `~/.gitlawb`. The rules live in `gitlawb-core`
|
||||
/// because `git-remote-gitlawb` needs the identical answer: an operator who moved
|
||||
/// their key (`GITLAWB_KEY=/data/keys/identity.pem`, the shape `.env.example`
|
||||
/// documents) would otherwise have `gl ucan import` write the delegation to
|
||||
/// `~/.gitlawb/delegations` while the helper reads `/data/keys/delegations` and
|
||||
/// finds it empty. The push then goes out with no `X-Ucan` and the delegate is
|
||||
/// refused, with nothing on either side to indicate why.
|
||||
pub fn gitlawb_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(d) = override_dir {
|
||||
return Ok(d);
|
||||
}
|
||||
let home = dirs::home_dir().context("could not determine home directory")?;
|
||||
Ok(home.join(".gitlawb"))
|
||||
// `home_dir()` is passed as an Option rather than demanded here: an absolute
|
||||
// GITLAWB_KEY resolves without a home directory, and a host that has none is a
|
||||
// normal container shape, not a reason to refuse a correctly-configured key.
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_dir(home.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
|
||||
fn key_path(dir: &Path) -> PathBuf {
|
||||
dir.join("identity.pem")
|
||||
/// The identity PEM to read or write.
|
||||
///
|
||||
/// With an explicit `--dir` this is `<dir>/identity.pem`, the conventional layout.
|
||||
/// Without one it is [`gitlawb_core::identity_path::identity_key_path`] — the whole
|
||||
/// of `GITLAWB_KEY`, basename included.
|
||||
///
|
||||
/// Taking only the parent and re-appending `identity.pem` was a real divergence,
|
||||
/// not a tidy-up: `GITLAWB_KEY` is documented as a path to a PEM, and
|
||||
/// `git-remote-gitlawb` opens exactly that path. With
|
||||
/// `GITLAWB_KEY=/data/keys/ci-agent.pem`, `gl identity new` wrote
|
||||
/// `/data/keys/identity.pem` while every push loaded `/data/keys/ci-agent.pem`, so
|
||||
/// the two either disagreed on identity or the helper found no key at all — and
|
||||
/// owner enforcement and the delegation proof both key off that identity.
|
||||
pub(crate) fn key_path_for(dir: Option<&Path>) -> Result<PathBuf> {
|
||||
match dir {
|
||||
Some(d) => Ok(d.join(gitlawb_core::identity_path::KEY_FILE_NAME)),
|
||||
None => {
|
||||
let home = dirs::home_dir();
|
||||
gitlawb_core::identity_path::identity_key_path(home.as_deref())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
@@ -81,15 +117,14 @@ fn load_keypair(dir: Option<PathBuf>) -> Result<Keypair> {
|
||||
|
||||
/// Load keypair from an optional directory override.
|
||||
/// Used by other modules (register, repo, mcp).
|
||||
///
|
||||
/// Routed through [`gitlawb_dir`] rather than reaching for `~/.gitlawb` directly:
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so a second
|
||||
/// resolver here would have every other command read a different file than the one
|
||||
/// just created — `gl ucan delegate` would either fail to find an identity or sign
|
||||
/// with a stale DID that is not the repo owner.
|
||||
pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
let path = key_path(&base);
|
||||
let path = key_path_for(dir)?;
|
||||
let pem = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no identity found at {}\nRun `gl identity new` to create one",
|
||||
@@ -105,11 +140,20 @@ pub fn load_optional_keypair(dir: Option<&std::path::Path>) -> Result<Option<Key
|
||||
if let Some(d) = dir {
|
||||
return load_keypair_from_dir(Some(d)).map(Some);
|
||||
}
|
||||
let base = match dirs::home_dir() {
|
||||
Some(h) => h.join(".gitlawb"),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let path = key_path(&base);
|
||||
// Route the default lookup through key_path_for(None), not a hand-built
|
||||
// ~/.gitlawb/identity.pem: that path pinned the conventional basename and
|
||||
// ignored GITLAWB_KEY, so callers on this helper (task list/view, the MCP
|
||||
// identity fallback) signed as a stale DID or went anonymous while every
|
||||
// sibling command read the configured key. A host with no home and no
|
||||
// GITLAWB_KEY still resolves to anonymous rather than refusing.
|
||||
// An empty GITLAWB_KEY counts as unconfigured, matching identity_key_path:
|
||||
// set-but-empty should not force an anonymous resolution through a path
|
||||
// that cannot resolve.
|
||||
let key_configured = std::env::var_os("GITLAWB_KEY").is_some_and(|v| !v.is_empty());
|
||||
if !key_configured && dirs::home_dir().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = key_path_for(None)?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(pem) => Keypair::from_pem(&pem)
|
||||
.context("failed to load keypair from PEM")
|
||||
@@ -129,8 +173,11 @@ async fn cmd_new_with_reader(
|
||||
force: bool,
|
||||
reader: &mut impl std::io::BufRead,
|
||||
) -> Result<()> {
|
||||
let dir = gitlawb_dir(dir)?;
|
||||
let path = key_path(&dir);
|
||||
let path = key_path_for(dir.as_deref())?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if path.exists() {
|
||||
if force {
|
||||
@@ -194,8 +241,7 @@ async fn cmd_sign(message: String, dir: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
|
||||
async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let src = key_path(&base);
|
||||
let src = key_path_for(dir.as_deref())?;
|
||||
|
||||
let pem = fs::read_to_string(&src).with_context(|| {
|
||||
format!(
|
||||
@@ -241,8 +287,11 @@ async fn cmd_restore_with_reader(
|
||||
// Verify it's a valid keypair before writing anything
|
||||
let keypair = Keypair::from_pem(&pem).context("backup file is not a valid identity PEM")?;
|
||||
|
||||
let base = gitlawb_dir(dir)?;
|
||||
let dest = key_path(&base);
|
||||
let dest = key_path_for(dir.as_deref())?;
|
||||
let base = dest
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if dest.exists() {
|
||||
if force {
|
||||
@@ -281,6 +330,36 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn load_optional_keypair_honors_gitlawb_key_basename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let kp = Keypair::generate();
|
||||
let custom = dir.path().join("ci-agent.pem");
|
||||
std::fs::write(&custom, kp.to_pem().unwrap()).unwrap();
|
||||
crate::identity::test_env::with_key(Some(custom.as_os_str()), || {
|
||||
let loaded = load_optional_keypair(None).unwrap();
|
||||
assert_eq!(
|
||||
loaded.map(|k| k.did().to_string()),
|
||||
Some(kp.did().to_string()),
|
||||
"the configured basename must be read, not ~/.gitlawb/identity.pem"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_optional_keypair_none_when_unset_and_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
crate::identity::test_env::with_key(
|
||||
Some(dir.path().join("absent.pem").as_os_str()),
|
||||
|| {
|
||||
assert!(
|
||||
load_optional_keypair(None).unwrap().is_none(),
|
||||
"a configured-but-absent key resolves anonymous, not an error"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_new_creates_pem() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -502,3 +581,186 @@ mod tests {
|
||||
assert_eq!(original_did, dst_kp.did());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoped `GITLAWB_KEY` for tests, shared crate-wide.
|
||||
///
|
||||
/// The process environment is global and more than one suite in this crate
|
||||
/// depends on it — the resolver's own cases here, and `gl register`'s check that
|
||||
/// the bootstrap token lands beside the key. They take one lock rather than each
|
||||
/// declaring its own, which would not serialise them against each other.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_env {
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Restores the previous value and releases the lock on drop.
|
||||
pub(crate) struct KeyEnv {
|
||||
_guard: MutexGuard<'static, ()>,
|
||||
restore: Option<OsString>,
|
||||
}
|
||||
|
||||
impl Drop for KeyEnv {
|
||||
fn drop(&mut self) {
|
||||
match self.restore.take() {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set, restoring it afterwards.
|
||||
pub(crate) fn with_key<T, V: AsRef<OsStr>>(value: Option<V>, f: impl FnOnce() -> T) -> T {
|
||||
let _guard = set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Set `GITLAWB_KEY` (or remove it, for `None`) until the guard drops.
|
||||
pub(crate) fn set_key<V: AsRef<OsStr>>(value: Option<V>) -> KeyEnv {
|
||||
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let restore = std::env::var_os("GITLAWB_KEY");
|
||||
match value {
|
||||
Some(v) => std::env::set_var("GITLAWB_KEY", v),
|
||||
None => std::env::remove_var("GITLAWB_KEY"),
|
||||
}
|
||||
KeyEnv {
|
||||
_guard: guard,
|
||||
restore,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gitlawb_dir_tests {
|
||||
use super::{gitlawb_dir, load_keypair_from_dir};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Run `f` with `GITLAWB_KEY` set to `value` (or removed for `None`), restoring
|
||||
/// whatever was there before.
|
||||
fn with_key_env<T>(value: Option<OsString>, f: impl FnOnce() -> T) -> T {
|
||||
let _env = crate::identity::test_env::set_key(value);
|
||||
f()
|
||||
}
|
||||
|
||||
/// An explicit --dir always wins and is never validated against GITLAWB_KEY.
|
||||
#[test]
|
||||
fn explicit_override_wins() {
|
||||
let d = PathBuf::from("/tmp/explicit");
|
||||
assert_eq!(gitlawb_dir(Some(d.clone())).unwrap(), d);
|
||||
}
|
||||
|
||||
/// A relative GITLAWB_KEY must fail loudly. `gl` and `git-remote-gitlawb` run
|
||||
/// from different working directories, so resolving one relatively sends the
|
||||
/// import and the lookup to different stores; a one-component value yields an
|
||||
/// empty parent and puts the store in `./delegations`.
|
||||
#[test]
|
||||
fn relative_key_paths_are_refused() {
|
||||
for raw in ["identity.pem", "keys/identity.pem"] {
|
||||
let result = with_key_env(Some(OsString::from(raw)), || gitlawb_dir(None));
|
||||
assert!(result.is_err(), "{raw} is relative and must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty value is what a shell leaves behind for `FOO=` and for an unset
|
||||
/// variable expanded into a wrapper script. Treated as unset, not as an error.
|
||||
#[test]
|
||||
fn an_empty_key_path_is_treated_as_unset() {
|
||||
// A host without a home dir is a legitimate CI shape; the empty-is-unset
|
||||
// rule resolves to anonymous there, so this assertion has nothing to
|
||||
// compare against and is a pass, not a panic.
|
||||
let Some(home) = dirs::home_dir() else { return };
|
||||
let result = with_key_env(Some(OsString::new()), || gitlawb_dir(None));
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
home.join(".gitlawb"),
|
||||
"an empty value selects the default directory"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reason `gitlawb_dir` reads through `var_os`: `env::var` folds a non-UTF-8
|
||||
/// value into the same `Err` as unset, so a bad path would silently resolve to
|
||||
/// `~/.gitlawb` instead of being reported. Byte 0xFF is not valid UTF-8 in any
|
||||
/// position, so this value is unreachable through `env::var`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_utf8_key_path_is_not_mistaken_for_unset() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let raw = OsString::from_vec(b"keys/\xFF/identity.pem".to_vec());
|
||||
let result = with_key_env(Some(raw), || gitlawb_dir(None));
|
||||
|
||||
let err = result.expect_err("a non-UTF-8 relative path must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("absolute"),
|
||||
"the error must name the real problem, not fall back to the default: {err}"
|
||||
);
|
||||
|
||||
let mut absolute = OsString::from("/data/");
|
||||
absolute.push(OsString::from_vec(vec![0xFF]));
|
||||
absolute.push("/identity.pem");
|
||||
let resolved = with_key_env(Some(absolute.clone()), || gitlawb_dir(None)).unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from(&absolute).parent().unwrap(),
|
||||
"an absolute non-UTF-8 path resolves to its own parent, not to ~/.gitlawb"
|
||||
);
|
||||
}
|
||||
|
||||
/// `gl identity new` writes the key wherever `GITLAWB_KEY` points, so every
|
||||
/// other command has to read it back from there. `load_keypair_from_dir(None)`
|
||||
/// used to hardcode `~/.gitlawb`, which made `gl ucan delegate` sign with a
|
||||
/// stale DID — or fail outright — for exactly the operators who moved the key.
|
||||
#[test]
|
||||
fn load_keypair_from_dir_honours_the_key_env() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("identity.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let loaded = with_key_env(Some(key.into_os_string()), || load_keypair_from_dir(None))
|
||||
.expect("the identity beside GITLAWB_KEY must be found");
|
||||
|
||||
assert_eq!(loaded.did(), expected.did());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_basename_tests {
|
||||
use super::*;
|
||||
|
||||
/// `GITLAWB_KEY` names a FILE. `gl` used to keep only its parent and re-append
|
||||
/// `identity.pem`, while `git-remote-gitlawb` opened the configured path — so
|
||||
/// with `GITLAWB_KEY=/data/keys/ci-agent.pem` the CLI and the push path loaded
|
||||
/// different files, and owner enforcement and the delegation proof both key off
|
||||
/// whichever identity that was.
|
||||
#[test]
|
||||
fn a_non_default_key_basename_is_honoured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key = dir.path().join("ci-agent.pem");
|
||||
let expected = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(&key, expected.to_pem().unwrap()).unwrap();
|
||||
|
||||
let (resolved, loaded) = crate::identity::test_env::with_key(Some(key.clone()), || {
|
||||
(key_path_for(None).unwrap(), load_keypair_from_dir(None))
|
||||
});
|
||||
|
||||
assert_eq!(resolved, key, "the configured basename must survive");
|
||||
assert_eq!(
|
||||
loaded.expect("the key at GITLAWB_KEY must load").did(),
|
||||
expected.did(),
|
||||
"gl must load the same file the helper opens"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit --dir keeps the conventional layout.
|
||||
#[test]
|
||||
fn an_explicit_dir_still_uses_identity_pem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
key_path_for(Some(dir.path())).unwrap(),
|
||||
dir.path().join("identity.pem")
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -26,7 +26,7 @@ pub struct InitArgs {
|
||||
)]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -105,10 +105,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
// Save UCAN if returned
|
||||
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
|
||||
if !ucan.is_empty() {
|
||||
let ucan_dir = args
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
let ucan_dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
crate::secret_file::create_dir(&ucan_dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -225,20 +222,26 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::identity::Keypair> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
let path = crate::identity::key_path_for(dir)?;
|
||||
let base = path
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = base.join("identity.pem");
|
||||
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
crate::secret_file::write_new(&path, pem.as_bytes()).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
anyhow::anyhow!(
|
||||
"identity key already exists at {} — refusing to overwrite; pass --dir for a fresh location or move the key",
|
||||
path.display()
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(keypair)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub enum IpfsCmd {
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
@@ -79,7 +79,7 @@ pub enum IpfsCmd {
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Resume token from a scan that stopped at a bound, as printed by a
|
||||
|
||||
+436
-33
@@ -286,7 +286,7 @@ fn tool_definitions() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "ucan_show",
|
||||
"description": "Show the saved bootstrap UCAN token for this agent.",
|
||||
"description": "Show the saved bootstrap UCAN for this agent: issuer, audience, capabilities, expiry, and signature validity. The token itself is not returned.",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
},
|
||||
{
|
||||
@@ -564,7 +564,7 @@ fn tool_definitions() -> Value {
|
||||
// ── UCAN delegation tools ───────────────────────────────────────────
|
||||
{
|
||||
"name": "ucan_delegate",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity.",
|
||||
"description": "Delegate capabilities to another agent by issuing a signed UCAN token. Requires agent identity. The recipient stores it with `gl ucan import <token>` or the `ucan_import` tool so their `git push` can present it.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["to", "resource", "action"],
|
||||
@@ -572,13 +572,13 @@ fn tool_definitions() -> Value {
|
||||
"to": { "type": "string", "description": "Audience DID — who receives this capability (e.g. did:key:z6Mk...)" },
|
||||
"resource": { "type": "string", "description": "Resource URI (e.g. gitlawb://repos/owner/repo)" },
|
||||
"action": { "type": "string", "description": "Action to grant (e.g. git/push, pr/open, repo/admin)" },
|
||||
"expiry_hours": { "type": "integer", "description": "Expiry in hours (optional, default: no expiry)" }
|
||||
"expiry_hours": { "type": "integer", "minimum": 1, "description": "Expiry in hours (default: 720, the same as `gl ucan delegate`). Every link of a push chain must expire, so there is no unbounded option." }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_verify",
|
||||
"description": "Verify a UCAN token's signature and expiry. Returns structured verification result.",
|
||||
"description": "Verify a UCAN token: signature, expiry, and the proof chain back to its root issuer. `valid` is true only when all three hold.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
@@ -587,6 +587,17 @@ fn tool_definitions() -> Value {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ucan_import",
|
||||
"description": "Store a delegation received from a repository owner where `git push` (git-remote-gitlawb) will present it. Same checks as `gl ucan import`: addressed to this identity, owner-rooted, every link bounded and naming one repository. Requires agent identity.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["token"],
|
||||
"properties": {
|
||||
"token": { "type": "string", "description": "UCAN token JSON, as returned by ucan_delegate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Issue tools ─────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -786,15 +797,45 @@ async fn call_tool(
|
||||
]))?),
|
||||
|
||||
"ucan_show" => {
|
||||
let ucan_path = dirs::home_dir()
|
||||
.context("no home dir")?
|
||||
.join(".gitlawb/ucan.json");
|
||||
if ucan_path.exists() {
|
||||
let content = std::fs::read_to_string(ucan_path)?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Ok("No UCAN saved. Run `gl register` or use the agent_register tool.".to_string())
|
||||
// The directory the server was started with, like every sibling tool.
|
||||
// Reading the default here while the rest honour `--dir` splits one MCP
|
||||
// session across two identity directories.
|
||||
let ucan_path = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?
|
||||
.join("ucan.json");
|
||||
if !ucan_path.exists() {
|
||||
return Ok(
|
||||
"No UCAN saved. Run `gl register` or use the agent_register tool.".to_string(),
|
||||
);
|
||||
}
|
||||
let content = std::fs::read_to_string(&ucan_path)?;
|
||||
// Decoded fields, matching what `gl ucan show` reports — the two are the
|
||||
// same question asked through different surfaces and should not answer
|
||||
// it in different shapes.
|
||||
//
|
||||
// Returning the file verbatim also handed the caller the bootstrap token
|
||||
// itself. That is the credential the agent presents, and an MCP response
|
||||
// travels further than a terminal: into a model's context, transcripts,
|
||||
// and logs. The fields below are what a caller actually needs to know
|
||||
// whether it is registered and until when.
|
||||
let ucan = crate::ucan_cmd::decode_saved_ucan(&content).with_context(|| {
|
||||
format!("could not read the saved UCAN at {}", ucan_path.display())
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"issuer": crate::ucan_cmd::shown(&ucan.payload.iss.to_string()),
|
||||
"audience": crate::ucan_cmd::shown(&ucan.payload.aud.to_string()),
|
||||
"version": crate::ucan_cmd::shown(&ucan.payload.ucan),
|
||||
"capabilities": ucan.payload.att.iter()
|
||||
.map(|c| json!({ "with": crate::ucan_cmd::shown(&c.with), "can": crate::ucan_cmd::shown(&c.can) }))
|
||||
.collect::<Vec<_>>(),
|
||||
"expires": ucan.payload.exp.map(|e| {
|
||||
chrono::DateTime::from_timestamp(e, 0)
|
||||
.map(|d| d.to_rfc3339())
|
||||
.unwrap_or_else(|| e.to_string())
|
||||
}),
|
||||
"expired": ucan.is_expired(),
|
||||
"signature_valid": ucan.verify_signature().is_ok(),
|
||||
"path": ucan_path.display().to_string(),
|
||||
}))?)
|
||||
}
|
||||
|
||||
"did_resolve" => {
|
||||
@@ -1200,14 +1241,47 @@ async fn call_tool(
|
||||
let resource = args["resource"].as_str().context("missing 'resource'")?;
|
||||
let action = args["action"].as_str().context("missing 'action'")?;
|
||||
|
||||
// The same canonicalization `gl ucan delegate` applies, so a token
|
||||
// minted here names the repository the way import, the helper, and
|
||||
// the node all resolve it.
|
||||
let resource = crate::ucan_cmd::canonical_resource(resource);
|
||||
let resource = resource.as_str();
|
||||
|
||||
// The same refusal `gl ucan delegate` applies. A push-class wildcard cannot
|
||||
// be honoured anywhere downstream — import refuses it, the helper refuses
|
||||
// it, the node refuses it — so issuing one only manufactures a token that
|
||||
// fails later with less context than this.
|
||||
if gitlawb_core::ucan::push::is_push_wildcard(resource, action) {
|
||||
anyhow::bail!(
|
||||
"a wildcard resource cannot carry a push capability: a delegation is \
|
||||
scoped to the repositories it names when issued, and `*` cannot say \
|
||||
which those were. Use resource gitlawb://repos/<owner>/<repo>."
|
||||
);
|
||||
}
|
||||
|
||||
let audience: gitlawb_core::did::Did = to_str
|
||||
.parse()
|
||||
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let exp = args
|
||||
.get("expiry_hours")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
|
||||
// The CLI defaults to `DEFAULT_DELEGATION_EXPIRY_HOURS` and has no way
|
||||
// to issue an unbounded token. An omitted field here used to mean "no
|
||||
// expiry", which for a push capability is dead on arrival: import
|
||||
// refuses it and so does the node (`chain_lifetime_is_bounded`). Same
|
||||
// default, same rule.
|
||||
let hours = match args.get("expiry_hours") {
|
||||
None | Some(Value::Null) => crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64,
|
||||
Some(v) => v
|
||||
.as_i64()
|
||||
.context("invalid expiry_hours: expected an integer")?,
|
||||
};
|
||||
if hours < 1 {
|
||||
anyhow::bail!("expiry_hours must be at least 1: a token that has already expired grants nothing");
|
||||
}
|
||||
anyhow::ensure!(
|
||||
hours <= crate::ucan_cmd::MAX_EXPIRY_HOURS as i64,
|
||||
"expiry_hours is out of range ({hours}): a DateTime cannot represent it"
|
||||
);
|
||||
let exp = Some(chrono::Utc::now() + chrono::Duration::hours(hours));
|
||||
|
||||
let ucan = gitlawb_core::ucan::Ucan::issue(
|
||||
&kp,
|
||||
@@ -1230,23 +1304,26 @@ async fn call_tool(
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
let ucan =
|
||||
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
|
||||
let sig_valid = ucan.verify_signature().is_ok();
|
||||
let expired = ucan.is_expired();
|
||||
let caps: Vec<Value> = ucan
|
||||
.payload
|
||||
.att
|
||||
.iter()
|
||||
.map(|c| json!({ "with": c.with, "can": c.can }))
|
||||
.collect();
|
||||
// The same report `gl ucan verify` prints, so the two cannot disagree
|
||||
// about what "valid" means. In particular the proof chain is walked:
|
||||
// a leaf with a good signature on a broken chain used to report valid
|
||||
// here and then fail at import and at the node.
|
||||
let report = crate::ucan_cmd::VerifyReport::of(&ucan);
|
||||
Ok(serde_json::to_string_pretty(&report.to_json())?)
|
||||
}
|
||||
|
||||
"ucan_import" => {
|
||||
let token = args["token"].as_str().context("missing 'token'")?;
|
||||
// `gl ucan import` with a JSON answer: one set of checks, one store, so
|
||||
// an MCP-only agent can complete the delegated-push workflow without
|
||||
// a shell and without a second copy of the rules.
|
||||
let imported = crate::ucan_cmd::import_delegation(token, dir).await?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"valid": sig_valid && !expired,
|
||||
"signature_valid": sig_valid,
|
||||
"expired": expired,
|
||||
"issuer": ucan.payload.iss.to_string(),
|
||||
"audience": ucan.payload.aud.to_string(),
|
||||
"capabilities": caps,
|
||||
"expires": ucan.payload.exp,
|
||||
"owner": imported.owner,
|
||||
"repo": imported.repo,
|
||||
"root_issuer": imported.root,
|
||||
"expires": imported.expires,
|
||||
"path": imported.path.display().to_string(),
|
||||
}))?)
|
||||
}
|
||||
|
||||
@@ -1747,6 +1824,7 @@ mod tests {
|
||||
.collect();
|
||||
assert!(names.contains(&"ucan_delegate"));
|
||||
assert!(names.contains(&"ucan_verify"));
|
||||
assert!(names.contains(&"ucan_import"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1829,6 +1907,10 @@ mod tests {
|
||||
/// follows cursors and, when a guard stops it, says so in the payload.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_follows_cursors() {
|
||||
// The env-mutating suites serialize on test_env's lock; a resolver
|
||||
// reader that skips it can observe a half-set GITLAWB_KEY mid-race.
|
||||
// Taking it here (with the key absent) keeps this read deterministic.
|
||||
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let first = server
|
||||
.mock(
|
||||
@@ -1866,6 +1948,8 @@ mod tests {
|
||||
/// cursor, so the model cannot read it as the whole answer.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_flags_incomplete_results() {
|
||||
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
@@ -1896,6 +1980,7 @@ mod tests {
|
||||
/// A legacy response without pagination metadata is reported as incomplete via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_response_is_incomplete() {
|
||||
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
@@ -1924,6 +2009,7 @@ mod tests {
|
||||
/// A legacy response with limit > 200 stops after 1 page and does not claim completeness via MCP.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_legacy_limit_above_page_cap() {
|
||||
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let ids: Vec<String> = (0..200).map(|i| format!("t{i}")).collect();
|
||||
let tasks_json: Vec<Value> = ids.iter().map(|id| json!({ "id": id })).collect();
|
||||
@@ -2035,6 +2121,7 @@ mod tests {
|
||||
/// helper rejects it, so the model sees an invalid argument instead.
|
||||
#[tokio::test]
|
||||
async fn test_task_list_via_mcp_rejects_non_positive_limit() {
|
||||
let _key_guard = crate::identity::test_env::set_key(None::<&str>);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let m = server
|
||||
.mock(
|
||||
@@ -2182,6 +2269,38 @@ mod tests {
|
||||
assert_eq!(parsed["capability"]["can"], "git/push");
|
||||
}
|
||||
|
||||
/// The same refusal the CLI applies. `test_ucan_delegate_via_mcp` uses a named
|
||||
/// resource, so it could never catch the MCP arm issuing a wildcard the CLI
|
||||
/// refuses.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_refuses_a_push_class_wildcard() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
for action in ["git/push", "*", "repo/admin"] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "*",
|
||||
"action": action,
|
||||
"expiry_hours": 24,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("MCP must refuse a push-class wildcard like the CLI does");
|
||||
assert!(err.to_string().contains("wildcard"), "{action}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp() {
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
@@ -2210,6 +2329,204 @@ mod tests {
|
||||
assert_eq!(parsed["valid"], true);
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], true);
|
||||
assert_eq!(parsed["root_issuer"], kp.did().to_string());
|
||||
}
|
||||
|
||||
/// The round-ten P2: `ucan_verify` answered from the leaf alone. A leaf with a
|
||||
/// good signature sitting on a proof that never named its issuer reported
|
||||
/// `valid: true` here, and was then refused by import and by the node.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_verify_via_mcp_walks_the_proof_chain() {
|
||||
let alice = gitlawb_core::identity::Keypair::generate();
|
||||
let bob = gitlawb_core::identity::Keypair::generate();
|
||||
let eve = gitlawb_core::identity::Keypair::generate();
|
||||
let node = gitlawb_core::identity::Keypair::generate();
|
||||
let cap = || {
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/alice/r",
|
||||
"git/push",
|
||||
)]
|
||||
};
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
// Alice grants Bob; Eve presents Alice's grant as if it were hers. Eve's
|
||||
// own signature is fine — the break is in the chain.
|
||||
let root = gitlawb_core::ucan::Ucan::issue(&alice, bob.did(), cap(), Some(hour)).unwrap();
|
||||
let forged =
|
||||
gitlawb_core::ucan::Ucan::delegate(&eve, node.did(), cap(), Some(hour), &root).unwrap();
|
||||
assert!(
|
||||
forged.verify_signature().is_ok(),
|
||||
"fixture: the leaf signature is valid"
|
||||
);
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_verify",
|
||||
json!({"token": forged.encode().unwrap()}),
|
||||
"http://localhost",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["signature_valid"], true);
|
||||
assert_eq!(parsed["expired"], false);
|
||||
assert_eq!(parsed["chain_valid"], false);
|
||||
assert_eq!(
|
||||
parsed["valid"], false,
|
||||
"a broken proof chain must not report valid: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["chain_error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("proof chain broken"),
|
||||
"the reason must be reported: {parsed}"
|
||||
);
|
||||
assert!(parsed["root_issuer"].is_null());
|
||||
}
|
||||
|
||||
/// The workflow an MCP-only agent could not finish: it could be issued a
|
||||
/// delegation and verify it, but not store it where `git-remote-gitlawb`
|
||||
/// looks. `ucan_import` is `gl ucan import` behind a tool — same checks, same
|
||||
/// file — so a token the CLI would store lands in the same place, and one the
|
||||
/// CLI would refuse is refused with the same reason.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_import_via_mcp_stores_where_the_helper_looks() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let me = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
me.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let owner = gitlawb_core::identity::Keypair::generate();
|
||||
let owner_did = owner.did().to_string();
|
||||
let hour = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
me.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
let result = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": token}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect("a delegation addressed to this identity must import");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["owner"], owner_did);
|
||||
assert_eq!(parsed["repo"], "ci-repo");
|
||||
assert_eq!(parsed["root_issuer"], owner_did);
|
||||
assert_eq!(parsed["expires"], hour.timestamp());
|
||||
|
||||
let stored = crate::ucan_cmd::delegation_path(dir.path(), &owner_did, "ci-repo");
|
||||
assert_eq!(parsed["path"], stored.display().to_string());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&stored).unwrap(),
|
||||
token,
|
||||
"the token must be stored byte-for-byte where the helper reads it"
|
||||
);
|
||||
|
||||
// Same refusals as the CLI: a token addressed to someone else is unusable
|
||||
// here however well-formed it is.
|
||||
let stranger = gitlawb_core::identity::Keypair::generate();
|
||||
let misaddressed = gitlawb_core::ucan::Ucan::issue(
|
||||
&owner,
|
||||
stranger.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
format!("gitlawb://repos/{owner_did}/ci-repo"),
|
||||
"git/push",
|
||||
)],
|
||||
Some(hour),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
let err = call_tool(
|
||||
"ucan_import",
|
||||
json!({"token": misaddressed}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a token addressed to another identity must be refused");
|
||||
assert!(err.to_string().contains("addressed to"), "{err}");
|
||||
}
|
||||
|
||||
/// The round-ten P2: an omitted `expiry_hours` issued an unbounded token, which
|
||||
/// import and the node both refuse for push. The default is now the CLI's.
|
||||
#[tokio::test]
|
||||
async fn test_ucan_delegate_via_mcp_defaults_to_the_cli_expiry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let kp = gitlawb_core::identity::Keypair::generate();
|
||||
std::fs::write(
|
||||
dir.path().join("identity.pem"),
|
||||
kp.to_pem().unwrap().as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let result = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
let expires = parsed["expires"]
|
||||
.as_i64()
|
||||
.expect("an omitted expiry_hours must still produce an expiring token");
|
||||
let expected = (before
|
||||
+ chrono::Duration::hours(crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS as i64))
|
||||
.timestamp();
|
||||
assert!(
|
||||
(expires - expected).abs() <= 60,
|
||||
"expiry must default to the CLI's {} hours, got {expires} vs {expected}",
|
||||
crate::ucan_cmd::DEFAULT_DELEGATION_EXPIRY_HOURS
|
||||
);
|
||||
let token = gitlawb_core::ucan::Ucan::decode(parsed["token"].as_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
token.chain_lifetime_is_bounded(),
|
||||
"the token must satisfy the rule import and the node apply"
|
||||
);
|
||||
|
||||
// A token that has already expired grants nothing, so the request is a
|
||||
// mistake to report, not a token to mint.
|
||||
for bad in [json!(0), json!(-5)] {
|
||||
let err = call_tool(
|
||||
"ucan_delegate",
|
||||
json!({
|
||||
"to": audience.did().to_string(),
|
||||
"resource": "gitlawb://repos/test/repo",
|
||||
"action": "git/push",
|
||||
"expiry_hours": bad,
|
||||
}),
|
||||
"http://localhost",
|
||||
Some(dir.path()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a non-positive expiry must be refused");
|
||||
assert!(err.to_string().contains("expiry_hours"), "{bad}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2626,10 +2943,96 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_count_is_42() {
|
||||
fn test_tool_count_matches_the_shipped_list() {
|
||||
let tools = tool_definitions();
|
||||
let count = tools.as_array().unwrap().len();
|
||||
assert_eq!(count, 40, "expected 40 tools, got {count}");
|
||||
// 41 with `ucan_import`. Bump this when a tool is added or removed; the
|
||||
// point is that no tool is added without someone noticing.
|
||||
assert_eq!(count, 41, "expected 41 tools, got {count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ucan_show_tests {
|
||||
use super::*;
|
||||
|
||||
/// The MCP tool and `gl ucan show` answer the same question and must answer it
|
||||
/// in the same shape — decoded fields, not the file. Returning the file verbatim
|
||||
/// also handed back the bootstrap token, which an MCP response carries much
|
||||
/// further than a terminal does.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_returns_decoded_fields_and_withholds_the_token() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let issuer = gitlawb_core::identity::Keypair::generate();
|
||||
let audience = gitlawb_core::identity::Keypair::generate();
|
||||
let token = gitlawb_core::ucan::Ucan::issue(
|
||||
&issuer,
|
||||
audience.did(),
|
||||
vec![gitlawb_core::ucan::Capability::new(
|
||||
"gitlawb://repos/z6MkAbc/myrepo",
|
||||
gitlawb_core::ucan::caps::GIT_PUSH,
|
||||
)],
|
||||
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
)
|
||||
.unwrap()
|
||||
.encode()
|
||||
.unwrap();
|
||||
|
||||
// The envelope shape every writer produces.
|
||||
std::fs::write(
|
||||
dir.path().join("ucan.json"),
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"ucan": token,
|
||||
"node": "https://node.gitlawb.com",
|
||||
"did": issuer.did().to_string(),
|
||||
"saved_at": "2026-08-18T00:00:00Z",
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.expect("ucan_show must read the envelope every writer produces");
|
||||
let v: serde_json::Value = serde_json::from_str(&out).expect("must be JSON");
|
||||
|
||||
assert_eq!(v["issuer"], issuer.did().to_string());
|
||||
assert_eq!(v["audience"], audience.did().to_string());
|
||||
assert_eq!(v["capabilities"][0]["can"], "git/push");
|
||||
assert_eq!(v["expired"], false);
|
||||
assert_eq!(v["signature_valid"], true);
|
||||
|
||||
// NOT `!out.contains(&token)`: the token is itself JSON, so embedding it in
|
||||
// a pretty-printed response escapes every quote and that assertion passes
|
||||
// whether or not the token leaked. The signature is a bare base64 string that
|
||||
// survives escaping unchanged, so it is the substring that actually proves
|
||||
// absence.
|
||||
let sig = serde_json::from_str::<serde_json::Value>(&token).unwrap()["s"]
|
||||
.as_str()
|
||||
.expect("a UCAN carries its signature in `s`")
|
||||
.to_string();
|
||||
assert!(!sig.is_empty());
|
||||
assert!(
|
||||
!out.contains(&sig),
|
||||
"the bootstrap token must not be returned to an MCP caller"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<serde_json::Value>(&out)
|
||||
.unwrap()
|
||||
.get("ucan")
|
||||
.is_none(),
|
||||
"no field may carry the token itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unregistered agent gets a usable message, not a decode error.
|
||||
#[tokio::test]
|
||||
async fn ucan_show_reports_no_saved_ucan() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let out = call_tool("ucan_show", json!({}), "http://localhost", Some(dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.contains("No UCAN saved"), "got: {out}");
|
||||
}
|
||||
|
||||
// ── Gated read arms surface node denials, not fabricated results (#123) ──
|
||||
|
||||
+2
-10
@@ -168,16 +168,8 @@ pub async fn run(args: NameArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
|
||||
dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
})
|
||||
}
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
@@ -190,7 +182,7 @@ fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
}
|
||||
|
||||
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
|
||||
let path = identity_dir(dir).join("identity.pem");
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum NodeCmd {
|
||||
env = "GITLAWB_NODE"
|
||||
)]
|
||||
node: String,
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
@@ -328,12 +328,10 @@ pub async fn cmd_unstake(
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_did(dir: Option<PathBuf>) -> Result<String> {
|
||||
let base = dir.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
let path = base.join("identity.pem");
|
||||
// The shared resolver, not a local `~/.gitlawb`: `gl identity new` writes the
|
||||
// key wherever `GITLAWB_KEY` points, and the old fallback to `.` on a missing
|
||||
// home made the answer depend on the working directory.
|
||||
let path = crate::identity::key_path_for(dir.as_deref())?;
|
||||
let pem = std::fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"No identity at {} — run `gl identity new` first",
|
||||
|
||||
+63
-25
@@ -12,7 +12,7 @@ use serde_json::{json, Value};
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
const PUBLIC_NODE: &str = "https://node.twigpine.com";
|
||||
@@ -23,7 +23,7 @@ pub struct QuickstartArgs {
|
||||
#[arg(long, default_value = PUBLIC_NODE, env = "GITLAWB_NODE")]
|
||||
pub node: String,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
|
||||
@@ -39,19 +39,25 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!("and create your first repository.");
|
||||
println!();
|
||||
|
||||
let dir = args.dir.clone().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".gitlawb")
|
||||
});
|
||||
// The wizard generates the identity AND stores the bootstrap UCAN, so it has to
|
||||
// land where every later command reads from — the parent of `GITLAWB_KEY`, not
|
||||
// an unconditional `~/.gitlawb`.
|
||||
let dir = crate::identity::gitlawb_dir(args.dir.clone())?;
|
||||
|
||||
// ── Step 1: Identity ──────────────────────────────────────────────────
|
||||
println!("── Step 1: Identity ─────────────────────────────────────────────────");
|
||||
println!();
|
||||
|
||||
let pem_path = dir.join("identity.pem");
|
||||
let pem_path = crate::identity::key_path_for(args.dir.as_deref())?;
|
||||
let keypair = if pem_path.exists() {
|
||||
match load_keypair_from_dir(Some(&dir)) {
|
||||
// `args.dir.as_deref()`, not `Some(&dir)`. Passing the DIRECTORY made the
|
||||
// loader re-derive the basename as `identity.pem`, so with
|
||||
// `GITLAWB_KEY=/data/keys/ci-agent.pem` the check above found `ci-agent.pem`
|
||||
// while the load looked for a sibling that need not exist. The load must
|
||||
// target the file that was actually selected, and a file that exists but
|
||||
// fails to load is refused below, never regenerated over: `pem_path` can
|
||||
// name any file, and repository ownership and delegations are tied to its DID.
|
||||
match load_keypair_from_dir(args.dir.as_deref()) {
|
||||
Ok(kp) => {
|
||||
let did = kp.did();
|
||||
println!(" ✓ Identity already exists");
|
||||
@@ -59,14 +65,17 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
println!();
|
||||
kp
|
||||
}
|
||||
Err(_) => {
|
||||
println!(" Identity file exists but is unreadable. Regenerating...");
|
||||
generate_identity(&dir)?
|
||||
Err(e) => {
|
||||
anyhow::bail!(
|
||||
"identity at {} could not be loaded: {e}. Fix or move the file, \
|
||||
or run `gl identity new --force` to replace it deliberately.",
|
||||
pem_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" No identity found. Generating a new Ed25519 keypair...");
|
||||
generate_identity(&dir)?
|
||||
generate_identity(&dir, &pem_path)?
|
||||
};
|
||||
|
||||
let did = keypair.did().to_string();
|
||||
@@ -99,7 +108,14 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let ucan = payload["ucan"].as_str().unwrap_or("");
|
||||
if !ucan.is_empty() {
|
||||
// The messaging below derives from what was actually persisted, not
|
||||
// from the 2xx: a success response carrying no `ucan` skips the write,
|
||||
// and saying "UCAN saved" anyway sends the operator looking for a file
|
||||
// that was never created - then resurfaces later as a push rejection
|
||||
// with nothing pointing back here.
|
||||
let saved = if ucan.is_empty() {
|
||||
false
|
||||
} else {
|
||||
crate::secret_file::create_dir(&dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
@@ -111,17 +127,21 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
true
|
||||
};
|
||||
if saved {
|
||||
// Success belongs to the saved branch: a 2xx without a
|
||||
// usable ucan is not a registration — the response may not
|
||||
// even be JSON, and saying success would also leave a
|
||||
// stale ucan.json from another node in place.
|
||||
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
|
||||
println!(" ✓ Registered successfully");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
} else {
|
||||
// A 2xx without a usable ucan is not a registration; the
|
||||
// response may not even be JSON. Saying success here would
|
||||
// also leave a stale ucan.json from another node in place.
|
||||
println!(" ✗ Registration returned no UCAN (unexpected response body)");
|
||||
// No retry hint here: a `gl register` instruction without
|
||||
// this invocation's --dir would load a different identity.
|
||||
println!(
|
||||
" \u{00d7} Registration returned no UCAN (unexpected response body)"
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
@@ -129,7 +149,10 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
let status = resp.status();
|
||||
let body: Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body["message"].as_str().unwrap_or("unknown");
|
||||
println!(" ✗ Registration failed ({status}): {msg}");
|
||||
println!(
|
||||
" ✗ Registration failed ({status}): {}",
|
||||
sanitize_node_msg(msg)
|
||||
);
|
||||
println!(" You can retry with: gl register --node {}", args.node);
|
||||
println!();
|
||||
// Non-fatal — continue to repo creation
|
||||
@@ -215,7 +238,10 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
let status = resp.status();
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let msg = payload["message"].as_str().unwrap_or("unknown");
|
||||
println!(" ✗ Repo creation failed ({status}): {msg}");
|
||||
println!(
|
||||
" ✗ Repo creation failed ({status}): {}",
|
||||
sanitize_node_msg(msg)
|
||||
);
|
||||
println!(
|
||||
" Try manually: gl repo create {repo_name} --node {}",
|
||||
args.node
|
||||
@@ -236,15 +262,27 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(dir: &std::path::Path) -> Result<gitlawb_core::identity::Keypair> {
|
||||
fn generate_identity(
|
||||
dir: &std::path::Path,
|
||||
path: &std::path::Path,
|
||||
) -> Result<gitlawb_core::identity::Keypair> {
|
||||
crate::secret_file::create_dir(dir)
|
||||
.with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = dir.join("identity.pem");
|
||||
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
crate::secret_file::write_new(path, pem.as_bytes()).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
anyhow::anyhow!(
|
||||
"identity key already exists at {}: refusing to overwrite; \
|
||||
pass --dir for a fresh location or move the existing key",
|
||||
path.display()
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let did = keypair.did();
|
||||
println!(" ✓ Generated new identity");
|
||||
|
||||
+80
-23
@@ -1,14 +1,15 @@
|
||||
//! `gl register` — register this agent identity with a Twigpine node.
|
||||
//!
|
||||
//! Sends a signed POST /api/register request and saves the returned bootstrap
|
||||
//! UCAN token to `~/.gitlawb/ucan.json` for use by other commands.
|
||||
//! UCAN token as `ucan.json` beside the identity key, where the other commands
|
||||
//! look for it.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::http::NodeClient;
|
||||
use crate::http::{sanitize_node_msg, NodeClient};
|
||||
use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -33,7 +34,7 @@ pub struct RegisterArgs {
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
pub dir: Option<PathBuf>,
|
||||
}
|
||||
@@ -64,20 +65,20 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
// Save bootstrap UCAN
|
||||
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if !ucan.is_empty() {
|
||||
let ucan_path = ucan_path(args.dir.as_deref())?;
|
||||
let saved_to = if ucan.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let path = ucan_path(args.dir.as_deref())?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
crate::secret_file::write(
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
crate::secret_file::write(&path, serde_json::to_string_pretty(&record)?.as_bytes())?;
|
||||
tracing::debug!("saved UCAN to {}", path.display());
|
||||
Some(path)
|
||||
};
|
||||
|
||||
let trust = payload
|
||||
.get("trust_score")
|
||||
@@ -93,25 +94,39 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
.unwrap_or("");
|
||||
|
||||
println!();
|
||||
println!(" {message}");
|
||||
println!(" {}", sanitize_node_msg(message));
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN expires: {expires}");
|
||||
println!(" UCAN expires: {}", sanitize_node_msg(expires));
|
||||
println!();
|
||||
println!(" Bootstrap UCAN saved to ~/.gitlawb/ucan.json");
|
||||
println!(" You are now a verified agent on the Twigpine network.");
|
||||
// The real path, not the default one: `GITLAWB_KEY` moves it, and an operator
|
||||
// told to look in `~/.gitlawb` would find nothing there.
|
||||
// Registration without a stored token means the registration-gated
|
||||
// capabilities never arrived, so the closing line must not claim they did.
|
||||
match &saved_to {
|
||||
Some(path) => {
|
||||
println!(" Bootstrap UCAN saved to {}", path.display());
|
||||
println!(" You are now a verified agent on the gitlawb network.");
|
||||
}
|
||||
None => {
|
||||
println!(" The node returned no bootstrap UCAN, so this identity is not");
|
||||
println!(" a verified agent yet. Re-run `gl register` once the node issues one.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the bootstrap UCAN is stored: beside the identity key, always.
|
||||
///
|
||||
/// Routed through `gitlawb_dir` rather than resolving `~/.gitlawb` locally. The
|
||||
/// key is READ from the parent of `GITLAWB_KEY`, so writing the token anywhere
|
||||
/// else splits the two: registration succeeds, and `gl doctor`, `gl ucan show`,
|
||||
/// `gl init`, and `gl mcp ucan_show` all read `ucan.json` from the key's
|
||||
/// directory and report an unregistered identity.
|
||||
fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||
let base = if let Some(d) = dir {
|
||||
d.to_path_buf()
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
let base = crate::identity::gitlawb_dir(dir.map(std::path::Path::to_path_buf))?;
|
||||
crate::secret_file::create_dir(&base)
|
||||
.with_context(|| format!("failed to create {}", base.display()))?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
@@ -158,6 +173,48 @@ mod tests {
|
||||
assert_eq!(content["node"].as_str().unwrap(), server.url());
|
||||
}
|
||||
|
||||
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
|
||||
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
|
||||
/// silent split-brain: registration prints success, and every command that later
|
||||
/// reads `ucan.json` — `gl doctor`, `gl ucan show`, `gl init`, `gl mcp` — looks
|
||||
/// beside the key, finds nothing, and reports an unregistered identity.
|
||||
#[tokio::test]
|
||||
async fn register_saves_the_bootstrap_ucan_beside_the_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_identity(&dir);
|
||||
// No --dir: the destination has to come from GITLAWB_KEY alone.
|
||||
let _env = crate::identity::test_env::set_key(Some(dir.path().join("identity.pem")));
|
||||
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", "/api/register")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
run(RegisterArgs {
|
||||
node: server.url(),
|
||||
capabilities: vec!["git:push".to_string()],
|
||||
model: None,
|
||||
dir: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let beside_the_key = dir.path().join("ucan.json");
|
||||
assert!(
|
||||
beside_the_key.exists(),
|
||||
"the bootstrap UCAN must land beside GITLAWB_KEY, not in ~/.gitlawb"
|
||||
);
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(beside_the_key).unwrap()).unwrap();
|
||||
assert_eq!(content["ucan"].as_str().unwrap(), "eyJhbGci.test.token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_server_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -30,6 +30,38 @@ pub(crate) fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `contents` to a `path` that must not already exist.
|
||||
///
|
||||
/// `create_new` makes the existence check and the create one atomic open, so
|
||||
/// there is no check-then-write window where an attacker or a second `gl init`
|
||||
/// could slip a file in between. Same owner-only mode and `O_NOFOLLOW` as
|
||||
/// [`write`]. `AlreadyExists` means the key was present, never that it was
|
||||
/// touched.
|
||||
pub(crate) fn write_new(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)?;
|
||||
f.write_all(contents)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if path.exists() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"refusing to overwrite existing file",
|
||||
));
|
||||
}
|
||||
std::fs::write(path, contents)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create `path` (and missing parents) as an owner-only directory.
|
||||
///
|
||||
/// An existing directory is also re-pinned: a `~/.gitlawb` created before
|
||||
|
||||
+1884
-40
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ use crate::identity::load_keypair_from_dir;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct WhoamiArgs {
|
||||
/// Identity directory (default: ~/.gitlawb)
|
||||
/// Identity directory (default: the parent of $GITLAWB_KEY, else ~/.gitlawb)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
/// Node URL to query for registration info
|
||||
|
||||
+110
-11
@@ -145,9 +145,11 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
|
||||
|
||||
## Owner-only push
|
||||
|
||||
The node requires the authenticated pusher to be the repo owner on **every**
|
||||
branch. A push whose authenticated DID is not the repo owner is rejected with
|
||||
HTTP 403 before any ref update is applied. The owner is matched in both the full
|
||||
On the `git-receive-pack` POST — the push itself — the node requires the
|
||||
authenticated pusher to be the repo owner, or to present a UCAN whose proof chain
|
||||
roots at the owner and carries `git/push` for this repository (see *Delegating
|
||||
push to a CI agent* below). Anyone else is rejected with HTTP 403 before any ref
|
||||
update is applied, on **every** branch. The owner is matched in both the full
|
||||
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
|
||||
This is on by default, and the default is the point. The node authenticates every
|
||||
@@ -157,6 +159,25 @@ Authentication is not authorization, so without this gate every signed caller ca
|
||||
push to every repository — private ones included — on any branch that is not
|
||||
explicitly protected.
|
||||
|
||||
A `git push` crosses two independent gates, and this section is about the second:
|
||||
|
||||
1. **Read visibility**, enforced twice on a push: on the `info/refs` advertisement
|
||||
that precedes every fetch *and* every push, and again on the `git-receive-pack`
|
||||
POST itself, because a client can skip the advertisement entirely. The
|
||||
owner always passes. A visibility rule that matches `/` is an allow-list:
|
||||
only the DIDs in its reader list pass, on a public repository as much as on
|
||||
a private one. With no matching rule the repository's own flag decides:
|
||||
public admits everyone, private denies every non-reader with the same 404 a
|
||||
missing repository gets. A UCAN plays no part here — a delegation grants
|
||||
no read access.
|
||||
2. **Push authorization**, on the `git-receive-pack` POST: the owner, or an
|
||||
owner-rooted, bounded, repository-scoped push delegation.
|
||||
|
||||
Both must pass. With no `/` rule on a public repository the first is open to
|
||||
everyone, so a delegation alone is enough; with a `/` rule, or on a private
|
||||
repository, the delegate must also be a reader — see *Private repositories and
|
||||
`/` rules* below.
|
||||
|
||||
### Turning it off
|
||||
|
||||
```bash
|
||||
@@ -170,16 +191,94 @@ Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
|
||||
treat it as temporary: while it is off, your node accepts a push to any repository
|
||||
from anyone who can generate a keypair.
|
||||
|
||||
### Caution: delegated and CI keys are non-owners
|
||||
- **When `true`** — a push whose authenticated DID is not the repo owner is
|
||||
rejected (HTTP 403) before any ref update is applied, unless it carries a
|
||||
valid delegation (the next bullet). The owner is matched in
|
||||
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
|
||||
- **A delegated key can still push.** A non-owner clears this gate by presenting a
|
||||
UCAN whose proof chain roots at the repo owner and which carries `git/push` for
|
||||
this repository. See *Delegating push to a CI agent* below.
|
||||
|
||||
Push authorization is owner-only today. A UCAN `git/push` capability is verified
|
||||
but **not yet honored for authorization**, so a delegated key cannot push while
|
||||
this gate is on, even when it holds a valid capability for the repo.
|
||||
### Delegating push to a CI agent
|
||||
|
||||
If your automation pushes under its own DID rather than the owner's, it will start
|
||||
getting 403s. Either have it push as the owner, or set
|
||||
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
|
||||
rights land — that work is what removes this trade-off.
|
||||
The owner issues a capability, the agent stores it, and the git helper presents it
|
||||
automatically on every push:
|
||||
|
||||
Before anything below, set `GITLAWB_KEY` to an **absolute** path to the
|
||||
identity PEM (`gl identity new` prints it). `gl ucan import` and
|
||||
`git-remote-gitlawb` both resolve the delegation store from that key's parent
|
||||
directory, so do not pass `import --dir` pointing anywhere else: an import that
|
||||
succeeds elsewhere leaves the push unsigned. The README and `.env.example`
|
||||
cover the layout.
|
||||
|
||||
```bash
|
||||
# Owner, once per agent per repo:
|
||||
gl ucan delegate --to did:key:z6MkAgent… \
|
||||
--cap gitlawb://repos/<owner-did>/<repo> --can git/push --expiry 168
|
||||
|
||||
# Agent:
|
||||
gl ucan import <token-or-path>
|
||||
git push origin main # git-remote-gitlawb attaches it as X-Ucan
|
||||
```
|
||||
|
||||
`--expiry` is measured in hours: 168 is 7 days, 720 is 30.
|
||||
|
||||
`<repo>` is the repository's canonical name: the record name without a `.git`
|
||||
suffix. A trailing `.git` in the capability (or pasted from the remote URL) is
|
||||
stripped the same way the git remote strips it, at most once, so
|
||||
`gitlawb://repos/<owner-did>/myrepo.git` delegates to `myrepo`.
|
||||
|
||||
What the node requires, and why:
|
||||
|
||||
| Requirement | Reason |
|
||||
|---|---|
|
||||
| The chain's **root issuer** is the repo owner | A `did:key` is self-certifying, so anyone can mint a chain. The owner is the only anchor the node holds independently of the token. |
|
||||
| **Every link** names this repository | A delegation's scope is fixed when it is issued. The node walks the whole proof chain, so a `*` resource authorizes nothing — not in the leaf and not in any proof behind it. Refusing only the leaf would not have been enough: attenuation permits narrowing a `*` parent to a concrete child, so one wildcard grant could otherwise reach every repository the owner has or later creates. `gl ucan delegate` therefore refuses to issue a push-class `*` at all. |
|
||||
| **Every link carries an expiry** | There is no revocation path yet. An unbounded delegation could never be withdrawn once leaked, so the node refuses one outright. `gl ucan delegate` defaults to 30 days. |
|
||||
| No `nb` constraints | Constraints are reserved but not yet interpreted, so a capability carrying them authorizes nothing rather than silently granting more than the owner intended. |
|
||||
| The token is addressed to the pusher | The node requires the proof's audience to equal the invocation's issuer, and `gl ucan import` refuses a token addressed to another identity rather than storing one that can only fail. |
|
||||
|
||||
**Private repositories and `/` rules.** A delegation clears the push gate
|
||||
only. The `info/refs` advertisement that `git push` fetches first is read-gated,
|
||||
and a UCAN grants no read access, so on a private repository the delegate must
|
||||
also be a reader — and the same holds on a public repository that has a `/`
|
||||
rule, because a matching rule is an allow-list that replaces the public flag:
|
||||
|
||||
```bash
|
||||
# Owner: check the existing rules first — a `/` rule you already set has a mode
|
||||
# you want to keep, and `set` replaces its reader list wholesale.
|
||||
gl visibility list --repo <repo>
|
||||
|
||||
# Then grant read to the agent AND every existing reader on `/`. The mode
|
||||
# (`a`/`b`) must match what `list` showed; only reach for `b` when no `/`
|
||||
# rule exists yet.
|
||||
gl visibility set / --repo <repo> --mode <a|b> --readers did:key:z6MkReader1,did:key:z6MkAgent…
|
||||
```
|
||||
|
||||
Without it the agent's push fails with a 404 — the same answer a stranger gets,
|
||||
because a private repository is not confirmed to exist for a non-reader. A
|
||||
normal `git push` sees it at the `info/refs` advertisement; a client that skips
|
||||
the advertisement sees the identical denial on the `git-receive-pack` POST
|
||||
itself. Either way the delegation is valid and would clear the push gate: the
|
||||
read gate is what refused it. On a public repository with no `/` rule this
|
||||
step is unnecessary. The two gates are
|
||||
independent in the other direction as well: a reader with no delegation is still
|
||||
refused on the push.
|
||||
|
||||
**One repository per token.** `gl ucan delegate` issues one capability per token,
|
||||
and `gl ucan import` stores one repository per token. A hand-built token that
|
||||
names several repositories is refused at import rather than applied to some of
|
||||
them; ask the owner for one delegation per repository.
|
||||
|
||||
**A delegation does not override branch protection.** A protected branch is your
|
||||
explicit marker that even routine writes should stop, so a delegate is still
|
||||
refused there and only the owner may push. That is deliberate: if a delegation
|
||||
overrode it, issuing any capability would weaken every protection you had set.
|
||||
|
||||
**Withdrawal is by expiry only.** There is no revocation today. If a delegated
|
||||
token leaks, it remains valid until its `exp`, and the only faster remedy is
|
||||
rotating the owner DID the repository is keyed on. Choose `--expiry` accordingly —
|
||||
short lifetimes reissued often are safer than one long-lived grant.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user