Compare commits

..
Author SHA1 Message Date
Kevin CodexandOpenClaude 7175409d02 fix(security): reject path traversal in repo_store local_path
Closes CodeQL alert #4 (Uncontrolled data used in path expression).

Both `owner_did` and `repo_name` come from URL parameters and were used
unsanitized to build a filesystem path, so a request like
`/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos
directory.

Fix:

- New `validate_owner_did` and `validate_repo_name` enforce a strict
  allowlist before path construction (alphanumeric + `: . _ -` for DIDs;
  alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading
  `.` or `-`, slashes, backslashes, null bytes, overlong inputs).
- `RepoStore::local_path` now returns `Result<…>` and the 5 callers
  propagate the error.
- Defence in depth: even after the allowlist passes, the joined path is
  checked to still be rooted at `repos_dir`.
- 17 unit tests cover normal DIDs/repo names plus every malicious shape
  (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with
  dots).

Co-Authored-By: OpenClaude <[email protected]>
2026-05-04 22:15:17 +08:00
219 changed files with 4944 additions and 151963 deletions

No files matched your search

-42
View File
@@ -1,42 +0,0 @@
# cargo-audit configuration.
#
# cargo-audit reads its config from .cargo/audit.toml (relative to the working
# directory) or ~/.cargo/audit.toml. It does NOT read a repo-root audit.toml,
# and there is no CLI flag to point at a config file (`-f` is the lockfile path).
# This file lives at .cargo/audit.toml for that reason.
#
# The audit job stays hard-fail: only the advisories explicitly listed below are
# suppressed, so any NEW advisory still breaks CI. Each entry below has no
# available upstream fix; everything fixable is fixed in Cargo.lock instead.
[advisories]
ignore = [
# rsa 0.9.10 (Marvin timing sidechannel). Present in Cargo.lock because sqlx
# resolves sqlx-mysql unconditionally, and sqlx-mysql depends on rsa. cargo
# audit scans the lockfile, so it flags rsa even though it is NOT linked into
# our binary: sqlx is built postgres-only (crates/gitlawb-node/Cargo.toml),
# so the MySQL RSA auth path is never compiled and there is no RSA oracle to
# attack. No upstream fix exists regardless. REMOVE this ignore if sqlx is
# ever built with the `mysql` feature, or any other consumer of rsa enters
# the build, at which point it becomes a real reachable advisory.
"RUSTSEC-2023-0071", # rsa Marvin attack (no fix; not linked in our build)
# crypto-bigint 0.5.5 (secret-dependent branches in operations meant
# to be constant-time). REACHABLE accepted risk, on two paths:
# - alloy-signer -> elliptic-curve 0.13 -> crypto-bigint 0.5: the k256
# PrivateKeySigner that signs transactions in `gl name`
# (crates/gl/src/name.rs) and the node operator
# (crates/gitlawb-node/src/operator.rs).
# - aws-sigv4 (`sigv4a` feature, crates/gitlawb-node/Cargo.toml) ->
# crypto-bigint 0.5 (directly and via p256 0.13): SigV4a key
# derivation, used only for requests to endpoints that require SigV4a
# (e.g. S3 Multi-Region Access Points).
# Exploiting it needs local timing observation of those signing operations.
# The fix is crypto-bigint >=0.7.6, and no compatible dependent uses it:
# every alloy-signer release (through 2.5.0) requires elliptic-curve ^0.13,
# and aws-sigv4 1.6.4 is the first release on crypto-bigint 0.7 (1.5.x -
# 1.6.3 still require ^0.5.4), but it needs Rust 1.94.1, above our 1.91 MSRV
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
]
+4 -301
View File
@@ -1,41 +1,12 @@
# Twigpine node — environment variables
# gitlawb node — environment variables
# Copy to .env and fill in your values.
# All variables are optional unless marked REQUIRED.
# ── Node identity ─────────────────────────────────────────────────────────
# 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.
# Path to the node's Ed25519 keypair PEM file.
# Generate with: gl identity new
GITLAWB_KEY=/data/keys/identity.pem
# Path to the node's persistent libp2p identity key file. Must include a
# directory, because the node will not keep its p2p identity key in the working
# directory. A value that cannot name a securable key file (a bare filename,
# `..` traversal, a trailing slash, 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 cwd or ancestor, an unreadable or malformed key) leaves p2p off
# while HTTP keeps serving, logged at error as p2p_identity_key_load_failed and
# counted by the gitlawb_p2p_identity_key_load_failed metric. /health still
# reports healthy in that state, so alert on the event or the metric. Fixing
# the storage and restarting restores the same PeerId.
# On Unix it is created 0600 inside a 0700 directory. A key directory that
# grants access beyond its owner is tightened to 0700 on start; one closed too
# far is refused with a chmod 700 remedy rather than widened. On other
# platforms no permissions are enforced.
# If the node logs that it tightened a key directory that was GROUP OR WORLD
# WRITABLE, treat the key that was sitting there as possibly exposed: delete
# it so a fresh identity is generated on the next start. An ordinary 0755
# directory is tightened too, but others could not read a 0600 key or replace
# its directory entry, so do not rotate for that case. A directory that was
# merely closed too far is refused, not tightened, and no key in it was ever
# exposed.
# Keep it on a persistent volume so the PeerId survives redeploys.
# Default: ~/.gitlawb/p2p.key
#GITLAWB_P2P_KEY=/data/keys/p2p.key
# Publicly reachable URL of this node (used in peer announcements)
GITLAWB_PUBLIC_URL=https://your-node.example.com
@@ -50,41 +21,6 @@ GITLAWB_REPOS_DIR=/data/repos
# When using the bundled docker-compose, this is wired automatically.
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
# ── Database pool & startup resilience ────────────────────────────────────
# Maximum connections in the PostgreSQL pool. A cap, not a floor —
# connections open lazily. Size against the DB server's max_connections,
# remembering admin tooling opens its own pool.
GITLAWB_DB_MAX_CONNECTIONS=48
# Maximum connections in the DEDICATED advisory-lock pool, separate from the
# pool above. Every in-flight repo write pins one connection here for its whole
# duration, so this is a hard ceiling on simultaneous writes node-wide: size it
# to expected peak concurrent writers, not small. Keeping it separate is what
# stops a push burst from starving ordinary request handlers. Budget
# (GITLAWB_DB_MAX_CONNECTIONS + this) per node against the server's
# max_connections, times node count, plus admin tooling.
GITLAWB_DB_LOCK_POOL_MAX_CONNECTIONS=40
# Upper bound, in seconds, on any object-storage transfer that runs while a
# per-repo write lock is HELD (the archive download inside acquire_write and the
# upload inside release). These were free before the lock's connection was
# pinned to the guard; now an unbounded stall holds a lock-pool slot, and enough
# stalls deny every write on the node. The bound applies PER SPAN and there are
# two (the acquire-side refresh, which covers the existence check and download
# together, and the release-side upload), so worst-case slot occupancy is about
# twice this value plus the git work between them. Read it together with the pool
# size above and with GITLAWB_GIT_SERVICE_TIMEOUT_SECS.
GITLAWB_LOCK_HELD_TRANSFER_TIMEOUT_SECS=300
# Seconds a request waits for a pool connection before failing with 503.
GITLAWB_DB_ACQUIRE_TIMEOUT_SECS=5
# Upper bound on each startup connect+migrate attempt, in seconds. Keep it
# generous enough for a peer instance to finish migrating (the migration
# advisory lock is held across a deploy); on expiry the attempt is retried.
GITLAWB_DB_CONNECT_TIMEOUT_SECS=60
# Startup retry backoff while the database is unavailable: first delay and
# ceiling, in seconds. The node stays up serving 503s (with a truthful
# /ready) instead of crash-looping.
GITLAWB_DB_RETRY_INITIAL_SECS=5
GITLAWB_DB_RETRY_MAX_SECS=60
# ── IPFS pinning (Pinata) ─────────────────────────────────────────────────
# Get a JWT at https://app.pinata.cloud/developers/api-keys
GITLAWB_PINATA_JWT=
@@ -127,246 +63,13 @@ POSTGRES_PASSWORD=changeme
# ── P2P networking ────────────────────────────────────────────────────────
GITLAWB_P2P_PORT=7546
# Comma-separated HTTP peer URLs to announce to on startup.
# The binary also ships with bootstrap-peers.json; set
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
# Comma-separated multiaddrs of bootstrap peers
GITLAWB_BOOTSTRAP_PEERS=
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# Require a peer write to prove its DID on every transport: RFC 9421 signatures
# on the announce/sync routes, and an Ed25519 payload signature on gossip
# ref-update events. Keep false during rolling upgrades so existing live nodes
# can still communicate. Upgrade every gossip-publishing peer to a build that
# signs events before setting this true, or their updates are dropped on
# arrival with no error visible on their side.
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Require the authenticated pusher to be the repo owner on git-receive-pack.
# A valid did:key signature is authentication, not authorization: anyone can
# mint a key, derive its DID and sign, so with this off every signed caller may
# 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.
GITLAWB_ENFORCE_OWNER_PUSH=true
# Comma-separated libp2p multiaddrs.
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
GITLAWB_P2P_BOOTSTRAP=
# ── Access control ────────────────────────────────────────────────────────
# Reserved and currently inert. Repo reads are gated per repository by
# is_public and path-scoped visibility rules; this flag changes nothing.
# Set to false to require auth on all read endpoints
GITLAWB_PUBLIC_READ=true
# Maximum git smart-HTTP pack request size, in bytes.
GITLAWB_MAX_PACK_BYTES=2147483648
# Max seconds a served git upload-pack / receive-pack (clone / push) may run
# before it is aborted with a 504. Bounds a hung git that would otherwise pin a
# worker and, on push, the repo write lock. Also bounds both info/refs
# advertisements, the withheld-blob pack build, and the push-side candidate
# discovery (rev-list / cat-file), all reaped via process-group teardown (#174).
# On the path-scoped upload-pack path the withheld-blob classification walk and
# the pack serve share ONE deadline, so this value bounds their COMBINED duration
# rather than giving each stage a full budget: a walk that consumes it leaves the
# serve nothing and the clone gets a 504. Serving large path-scoped repos may
# need a higher value here than when each stage was budgeted separately.
# Must be 1..=3153600000 (100 years): the node derives deadlines from this value,
# and a larger one cannot be represented. Default 600.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS=600
# Max seconds the storage-ACQUISITION phase of a served git op may run before the
# request is shed with a 503, separate from the git-run timeout above. A
# concurrency permit is taken before this phase and GITLAWB_GIT_SERVICE_TIMEOUT_SECS
# only starts once git spawns, so without this a stalled backend (a hung Tigris
# HEAD/GET, or a hung pg advisory-lock iteration on push) pins the permit and drains
# the pool until every later request 503s. On expiry the permit is released
# (fail-closed). Kept separate because acquisition and git execution are distinct
# cost centers. Must be positive; set very large to effectively disable. Default 30.
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS=30
# Max concurrent git READ ops (upload-pack + the upload-pack info/refs
# advertisement) served at once, a global pool separate from the push pool below.
# The anon receive-pack info/refs advertisement has its OWN pool (see below), not
# this one. Over-cap sheds a clean 503 + Retry-After. Anonymous reads draw from
# here, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER (below) so one
# caller cannot monopolize the pool. Default 128.
GITLAWB_MAX_CONCURRENT_GIT_OPS=128
# Max concurrent git-receive-pack (push) POST operations, in a pool separate from
# the read pool (GITLAWB_MAX_CONCURRENT_GIT_OPS) so anonymous reads cannot shed an
# authenticated push at admission. The anon receive-pack info/refs advertisement
# runs in a SEPARATE pool of the same size (disjoint from this one), so an
# advertisement flood cannot shed a push either. Over-cap sheds a 503 +
# Retry-After. Default 32.
GITLAWB_MAX_CONCURRENT_GIT_PUSHES=32
# Max pushes PARKED at once waiting for the per-repo write lease of the SAME repo.
# A parked push holds its already-buffered pack body in memory, so this bounds that
# memory for a hot repo. Past the cap the newest push sheds a clean 503 +
# Retry-After ("repo is busy with another push") instead of joining the queue; a
# push to a DIFFERENT repo is never affected. The lease holder is not counted.
# Raising GITLAWB_MAX_CONCURRENT_GIT_PUSHES does not raise this; set it explicitly.
# Default 8.
GITLAWB_REPO_LEASE_MAX_WAITERS=8
# Max concurrent post-push pin loops (IPFS + Pinata pin_new_objects) across all
# repos. The per-repo encrypt-task set caps the pin-task COUNT; this caps how many
# of those loops RUN CONCURRENTLY, and so how much MB-scale object-id list memory is
# held WHILE BEING PINNED. It does not bound retained memory in general: on the local
# IPFS path the caller builds its list before this pool is entered, so a loop parked
# waiting for a permit still holds its full list, and how many park is capped only
# per repo. Do not size memory from this knob alone. A loop DEFERS (waits) when the
# pool is full, never drops a pin. Default 8.
GITLAWB_MAX_CONCURRENT_PIN_TASKS=8
# Max concurrent read ops (upload-pack + the upload-pack info/refs advertisement)
# a single caller may hold, so one caller cannot monopolize the read pool. Keyed
# on the resolved SOURCE IP, never the DID: a signature does not move a caller off
# this cap. The source-IP key is only as granular as GITLAWB_TRUSTED_PROXY below:
# left unset, a node behind an edge/NAT keys all callers on the edge IP and this
# collapses to one global cap. Set GITLAWB_TRUSTED_PROXY for per-client keying; a
# high-fanout caller (CI behind one NAT) then needs the operator to raise this.
# Default 16.
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
# Two further per-source concurrency caps exist on the PUSH side but have NO
# environment variable of their own: both are DERIVED as
# GITLAWB_MAX_CONCURRENT_GIT_PUSHES / 8, with a floor of 1. They are not settable
# independently, so raising the push pool raises both proportionally, and the
# default push pool of 32 gives each a cap of 4.
# * The anon receive-pack info/refs advertisement cap: one source IP may hold at
# most this many slots in the dedicated advert pool, so saturating that pool
# takes ~8 distinct source IPs.
# * The authenticated receive-pack POST cap: one source IP may hold at most this
# many WRITE-pool slots. This one is load-bearing for push availability, since
# it is acquired before the global write permit: without it, one host minting
# disposable did:key identities could open enough slow pushes to monopolize the
# write pool and 503 every other source (the push rate limiter caps arrival
# rate, not in-flight concurrency, and owner enforcement can be turned off for
# a rolling upgrade).
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
# ── Push rate limiting (git-receive-pack flood brake) ─────────────────────
# Max receive-pack requests (info/refs advertisement + push POST) per client
# IP per hour. 0 disables. Default 600.
GITLAWB_PUSH_RATE_LIMIT=600
# ── /ipfs/{cid} visibility-walk admission (#174) ──────────────────────────
# GET /ipfs/{cid} runs a per-repo full-history git walk in a blocking thread to
# decide whether the caller may read a path-scoped blob. It is publicly reachable,
# so it is bounded to keep a permissionless caller from fanning out unbounded
# concurrent walks and exhausting blocking-pool threads + PIDs.
# Max concurrent /ipfs walks across all callers (a pool of its own, disjoint from
# the served-git pools). Over-cap sheds a 503. Default 32.
GITLAWB_MAX_CONCURRENT_IPFS_WALKS=32
# Max concurrent /ipfs walks a single SOURCE IP may hold (keyed like the git
# per-caller caps via GITLAWB_TRUSTED_PROXY; reject-before-insert bounded map).
# Default 4.
GITLAWB_IPFS_WALK_PER_SOURCE=4
# Max legacy (NULL-provenance) repos probed per single /ipfs request, bounding the
# scan-fallback fan-out (git cat-file per candidate repo) for an anonymous caller. A
# truncated scan sheds a retryable 503, never a false 404. Default 256.
GITLAWB_IPFS_MAX_LEGACY_PROBES=256
# Max repo ROWS one /ipfs request's legacy scan may read from the database. The probe
# ceiling above only counts once a probe runs, and quarantined or private repos are
# denied before that, so without this an all-denying inventory paged the whole repo
# table for one anonymous request. A truncated scan sheds a retryable 503 carrying a
# sealed `continuation` token; echo it as ?scan= to resume, so a holder buried past the
# ceiling is reachable in ceil(repos / ceiling) + 1 requests. Raising this also raises
# the per-caller /ipfs work allowance, since each page is charged to it. Lowering it
# sharpens a coarse oracle: laddering to the end reveals the node's total repo count to
# within one ceiling. Default 2048.
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS=2048
# Max EXPENSIVE path-scope visibility walks per single /ipfs request (only a
# blob in a path-scoped repo costs a full-history walk). Over-cap repos are
# skipped without a verdict and the scan continues; if the object is then found
# nowhere the request sheds a retryable 503 instead of a false 404. The effective
# cap is the tighter of this value and the node's internal per-request ceiling of
# 17, so values above 17 have no effect; lower values do tighten it. Default 64.
GITLAWB_IPFS_MAX_REPOS_WALKED=64
# Ceiling on repos one /ipfs request may VISIT past the visibility gate. Each
# visit costs a repo acquire — on a Tigris cache miss a full archive download,
# so this is also the worst-case object-store fetch count per request — plus a
# cat-file probe. On exhaustion the scan stops and sheds a retryable 503.
# Default 1024.
GITLAWB_IPFS_MAX_REPO_VISITS=1024
# Absolute wall-clock budget for one admitted /ipfs request's acquire+walk
# lifetime (all stages of the whole scan). Per-stage clamps bound the acquire
# and walk stages to the remaining budget, and no stage starts once it is
# exhausted (the scan then sheds a retryable 503). The object-type probe and
# content-read cat-file subprocesses are budget-checked before starting AND each
# run under their own deadline (the lesser of GITLAWB_GIT_SERVICE_TIMEOUT_SECS and
# the remaining budget), reaped via process-group teardown, so a hung cat-file
# cannot hold the request's walk slot past it. One hang path is still unbounded:
# the probe's object-store readability check is a plain filesystem sweep with
# nothing to reap, so a wedged filesystem can hold the slot past the deadline.
# Must be 1..=3153600000 (100 years): the node derives an Instant deadline from
# this value, and a larger one cannot be represented. Default 600.
GITLAWB_IPFS_REQUEST_BUDGET_SECS=600
# Shorter budget (seconds) for the pre-walk CID resolve: the lookup that maps a
# requested CID to its git oid(s), which runs while the scarce walk admission is
# already held. A well-formed CID with no pin row does no probe and no walk work,
# so without this a stalled lookup could hold a walk slot for the whole request
# budget while nothing walked. The effective deadline is the lesser of this and
# the remaining request budget; walk and probe work stay on the request budget,
# so a slow but progressing scan is never shed by it.
# Must be 1..=3153600000 (100 years). Default 10.
GITLAWB_IPFS_RESOLVE_BUDGET_SECS=10
# Max /ipfs/{cid} requests per client IP per hour (route flood brake, distinct
# from the concurrency caps above). 0 disables. Default 600.
GITLAWB_IPFS_RATE_LIMIT=600
# ── Creation rate limiting (repo/agent/issue/PR flood brake) ──────────────
# Max creation requests (POST /api/v1/repos, /api/register, fork, issues,
# pulls) per client IP per hour, in addition to the per-DID limit. The per-DID
# limit alone is bypassed by a did:key farm (one throwaway identity per repo),
# which is how spam-repo floods slip past it and the iCaptcha gate; the per-IP
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
# the client IP. 0 disables. Default 120.
GITLAWB_CREATE_RATE_LIMIT=120
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
# known peers and run at higher frequency, so a generous bucket. Separate from
# the trigger bucket so an unsigned notify flood can't drain trigger's quota.
# 0 disables. Default 600.
GITLAWB_PEER_WRITE_RATE_LIMIT=600
# /api/v1/sync/trigger requires a signature and fans out to every peer per call,
# so it gets a tight bucket. 0 disables. Default 60.
GITLAWB_SYNC_TRIGGER_RATE_LIMIT=60
# Which forwarded header the edge is trusted to set, used to resolve the real
# client IP for the push limiter. One of:
# (unset) — no trusted proxy: key on the socket peer address, ignore headers.
# fly — behind Fly's edge (trust Fly-Client-IP).
# x-forwarded-for — behind a single reverse proxy like Caddy/NGINX (trust the
# rightmost X-Forwarded-For hop).
# Only set this when a proxy you control actually fronts the node; trusting a
# forwarded header on a directly-exposed node lets clients spoof the key.
GITLAWB_TRUSTED_PROXY=
# ── Sync ─────────────────────────────────────────────────────────────────
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
# ── iCaptcha proof-of-intelligence gate ───────────────────────────────────
# Optional gate on create_repo + register: require callers to present an
# iCaptcha proof (X-ICaptcha-Proof header) earned at icaptcha.gitlawb.com.
# Default off = fully inert (no behavior change).
# off - gate disabled (default)
# shadow - verify + log would-be rejections, but always allow
# enforce - reject requests without a valid, strong-enough proof
ICAPTCHA_MODE=off
# Optional base64url Ed25519 public key override; if unset it is fetched from
# ICAPTCHA_URL/v1/pubkey at startup. Recommended in production: pinning the key
# removes the startup dependency on the iCaptcha service being reachable and
# avoids silently running inert if that one-time fetch fails.
ICAPTCHA_PUBKEY=
# Minimum proof difficulty level required to pass.
ICAPTCHA_REQUIRED_LEVEL=3
# Base URL of the iCaptcha service.
ICAPTCHA_URL=https://icaptcha.gitlawb.com
-11
View File
@@ -1,11 +0,0 @@
# Ownership for CI, release, and security-sensitive infrastructure.
# A PR that edits these paths requires review from a code owner once
# branch protection's "Require review from Code Owners" is enabled.
/.github/ @beardthelion
/Dockerfile @beardthelion
/Dockerfile.bins @beardthelion
/docker-compose.yml @beardthelion
/release-please-config.json @beardthelion
/.release-please-manifest.json @beardthelion
/SECURITY.md @beardthelion
-66
View File
@@ -1,66 +0,0 @@
name: Bug report
description: Report a defect in a Twigpine crate or node behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for the report. Please fill in the fields below so we can reproduce the issue.
For security vulnerabilities, do NOT open an issue — follow SECURITY.md instead.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: dropdown
id: crate
attributes:
label: Affected crate
options:
- gitlawb-node
- gl
- git-remote-gitlawb
- gitlawb-core
- not sure / other
validations:
required: true
- type: input
id: version
attributes:
label: Version or commit
description: Output of the binary's `--version`, or the git commit you built.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS, Rust version (`rustc --version`), and anything else relevant.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Paste logs or errors. This will be rendered as code, no need for backticks.
render: shell
validations:
required: false
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/Twigpine/node/blob/main/SECURITY.md
about: Please report security issues privately per our security policy, not as a public issue.
- name: Questions & discussion
url: https://github.com/Twigpine/node/discussions
about: For usage questions and open-ended discussion.
@@ -1,34 +0,0 @@
name: Feature request
description: Suggest an improvement or new capability
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem / use case
description: What are you trying to do that's hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
validations:
required: false
- type: dropdown
id: protocol
attributes:
label: Does this touch the protocol?
description: Identity, signatures, UCAN, ref certs, or P2P wire formats. Protocol changes need an issue discussion before any PR.
options:
- "No"
- "Yes"
- "Not sure"
validations:
required: true
-63
View File
@@ -1,63 +0,0 @@
<!--
Thanks for contributing to Twigpine. Keep PRs focused on one change.
Protocol-level changes (identity, signatures, wire format) should start
as an issue before code — see CONTRIBUTING.md.
-->
## Summary
<!-- One or two sentences: what changes and why it matters. -->
## Motivation & context
<!-- What problem does this solve? Link prior discussion if any. -->
Closes #
## Kind of change
- [ ] Bug fix
- [ ] Feature
- [ ] Security fix
- [ ] Docs
- [ ] Tests / CI
- [ ] Refactor (no behavior change)
- [ ] Breaking or protocol change (issue required first)
## What changed
<!-- Bullet the concrete changes. Note the crate(s) touched:
gitlawb-core / gitlawb-node / gl / git-remote-gitlawb -->
-
## How a reviewer can verify
<!-- Commands to run, or repro steps for a bug plus proof it's fixed. -->
```sh
```
## Before you request review
- [ ] Scope is one logical change; no unrelated churn
- [ ] `cargo test --workspace` passes locally
- [ ] New behavior is covered by tests (required for fixes)
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` are clean
- [ ] Commit titles use Conventional Commits (`feat(...)`, `fix(...)`, `docs(...)`)
- [ ] Docs / `.env.example` updated if behavior or config changed (or N/A)
- [ ] Checked existing PRs so this isn't a duplicate
## Protocol & signing impact
<!-- Delete this block if your change doesn't touch the protocol. -->
- [ ] Touches DID / `did:key`, Ed25519 / RFC 9421 signatures, UCAN, ref certs, or P2P wire formats
- [ ] Discussed in an issue before implementation
- [ ] Backward-compatible with existing nodes and previously signed history
## Notes for reviewers
<!-- Anything out of scope, follow-ups, or known limitations. Optional. -->
-88
View File
@@ -1,88 +0,0 @@
name: Scheduled Audit
# Self-expiring suppression check (companion to the PR-blocking audit gate in
# pr-checks.yml). Runs weekly. It re-runs cargo audit WITHOUT the .cargo/audit.toml
# ignore list to surface the full advisory set for visibility, and it hard-fails
# if the lockfile has moved off the pinned vulnerable versions while the ignores
# are still present, i.e. the upstream fix landed but the ignore was not dropped.
on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
scheduled-audit:
name: scheduled audit (no ignores)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: audit-schedule
- name: Install cargo-audit (floating; drift canary)
# Deliberately UNPINNED, unlike the blocking gate in pr-checks.yml. This
# job is cron-only (weekly); it is never pull_request- or push-triggered,
# so it NEVER gates a merge no matter what it does. Floating the scanner
# here is the drift canary: if a newer cargo-audit regresses, at worst
# this weekly run reds (the install step below is unguarded) or its
# visibility report changes, and that is exactly the signal that latest
# has moved and the pinned gate should not be bumped yet. The scan result
# itself never fails the run (the "Full audit report" step is `|| true`
# and the only other hard-fail is a Cargo.lock grep). The point is that
# drift surfaces here without ever blocking a PR. Do NOT pin to match
# pr-checks.yml.
run: cargo install --locked cargo-audit
# Full advisory report with NO suppressions. Run from a scratch dir that
# has no .cargo/audit.toml so the ignore list does not apply; never fail
# the build on this step, it is visibility only.
- name: Full audit report (ignores not applied)
run: |
mkdir -p "$RUNNER_TEMP/full-audit"
cp Cargo.lock "$RUNNER_TEMP/full-audit/Cargo.lock"
cd "$RUNNER_TEMP/full-audit"
{
echo '### Full cargo audit (no ignores applied)'
echo '```'
cargo audit -f Cargo.lock || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Drift guard: the hickory ignores are only valid while hickory-proto is
# pinned at 0.25.x. If the lockfile has moved past that, the upstream fix
# is in and the ignores in .cargo/audit.toml must be removed. Fail loudly.
- name: Fail if hickory moved past 0.25.x while ignores remain
run: |
set -euo pipefail
version="$(grep -A1 'name = "hickory-proto"' Cargo.lock | grep '^version' | head -1 | cut -d'"' -f2)"
echo "hickory-proto in Cargo.lock: ${version:-not present}"
ignores_present=false
if grep -q 'RUSTSEC-2026-011' .cargo/audit.toml; then
ignores_present=true
fi
# Drift = the lockfile no longer matches what the ignores assume:
# hickory moved off 0.25.x (fix shipped) OR was removed entirely
# (dead ignore entries left behind). Both must fail.
if [ "$ignores_present" = true ] && { [ -z "${version}" ] || [[ "${version}" != 0.25.* ]]; }; then
echo "::error::hickory-proto is ${version:-absent from Cargo.lock} but the RUSTSEC-2026-0118/0119 ignores are still in .cargo/audit.toml. The upstream fix appears to be available or the dependency is gone; remove the ignores."
exit 1
fi
echo "No drift: ignore list is consistent with the pinned hickory-proto version."
-121
View File
@@ -1,121 +0,0 @@
# Tracks main's CI state as a labeled issue: opens (or updates) a ci-main-red
# issue when a push-to-main run of PR Checks ends red, and closes it when a
# subsequent run on main's tip goes green.
#
# Why: two individually-green PRs can break main in combination when the
# second merges on checks that ran against a stale base (semantic merge
# race — #113 + #145 broke test compilation, found only via #229). A red main
# is otherwise silent, and release-please PRs inherit the breakage.
#
# The alert reflects main's CURRENT STATE, not raw CI events: runs whose
# commit is no longer main's tip are ignored (a re-run of an old red run
# after a fix has landed must not cry wolf), and a green run on the tip
# closes any open alert.
name: Main red alert
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
branches: [main]
permissions:
issues: write
contents: read # getBranch tip check
# Serialize runs: concurrent completions would race the label/issue lookups
# into duplicates (or 422 on createLabel). queue: max keeps every pending
# completion instead of letting a later (possibly green-skipped) arrival
# displace a pending red alert under burst merges; it is only incompatible
# with cancel-in-progress: true, which we don't use.
concurrency:
group: main-red-alert
cancel-in-progress: false
queue: max
jobs:
track:
name: Track main CI state
# timed_out (the test job runs 45 min against Postgres) and
# startup_failure (malformed workflow) are red too — only cancelled is
# excluded as a deliberate human abort. success runs are needed to close
# the alert once main recovers.
if: >-
github.event.workflow_run.event == 'push' &&
contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reconcile the broken-main issue with main's current state
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const label = 'ci-main-red';
// Freshness gate: only the run for main's current tip may open or
// close anything. Re-runs of superseded commits are events about
// the past, not statements about main's state.
const { data: branch } = await github.rest.repos.getBranch({
...context.repo, branch: 'main',
});
if (branch.commit.sha !== run.head_sha) {
core.info(`run ${run.id} is for ${run.head_sha}, main is at ${branch.commit.sha} — ignoring stale run`);
return;
}
// listForRepo returns PRs carrying the label too — those must
// never be mistaken for the tracking issue.
const open = (await github.rest.issues.listForRepo({
...context.repo, state: 'open', labels: label, per_page: 100,
})).data.filter(item => !item.pull_request);
if (run.conclusion === 'success') {
const body = `Recovered: [PR Checks](${run.html_url}) is green on main at \`${run.head_sha}\`.`;
for (const issue of open) {
await github.rest.issues.createComment({
...context.repo, issue_number: issue.number, body,
});
await github.rest.issues.update({
...context.repo, issue_number: issue.number, state: 'closed', state_reason: 'completed',
});
}
return;
}
const headline = run.head_commit?.message?.split('\n')[0] ?? '';
const body = [
`[PR Checks run](${run.html_url}) concluded \`${run.conclusion}\` on main at \`${run.head_sha}\`.`,
'',
`> ${headline}`,
'',
'Likely a semantic merge race: each merged PR was green on a stale base.',
'Fix main before merging anything else — release-please PRs inherit this breakage.',
'',
"This issue closes automatically when a run on main's tip goes green.",
].join('\n');
try {
await github.rest.issues.getLabel({ ...context.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
...context.repo,
name: label,
color: 'b60205',
description: 'CI is failing on main',
});
}
if (open.length > 0) {
await github.rest.issues.createComment({
...context.repo, issue_number: open[0].number, body,
});
} else {
await github.rest.issues.create({
...context.repo,
title: 'CI is red on main',
body,
labels: [label],
});
}
+5 -310
View File
@@ -2,17 +2,6 @@ name: PR Checks
on:
pull_request:
# Run in the merge queue so a PR is re-validated against the true merged
# result before it lands. Guards against semantic merge races where two
# individually-green PRs break main in combination (#113 + #145 → #229).
merge_group:
# Release-branch heads are created and pushed by GITHUB_TOKEN (release-please
# and the lock-sync job), and GitHub's recursion guard means those pushes
# trigger no workflow runs. workflow_dispatch is the documented carve-out that
# GITHUB_TOKEN CAN trigger, so the release flow dispatches this workflow at
# the release branch to attach check runs to its head. Read-only workflow
# (contents: read), so the dispatch surface adds no privilege.
workflow_dispatch:
push:
branches:
- main
@@ -20,25 +9,18 @@ on:
permissions:
contents: read
# Cancel superseded runs on the same PR/branch; never cancel runs on main.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
fmt-clippy:
name: fmt + clippy
fmt-clippy-test:
name: fmt + clippy + test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
@@ -53,294 +35,7 @@ jobs:
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --locked --workspace --all-targets -- -D warnings
release-tag-validation:
name: release tag validation
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Test release tag resolver
run: scripts/test-resolve-release-tag.sh
triage-detector:
name: triage detector matrix
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# pr-triage.yml runs on pull_request_target and never checks out PR
# code, so its inline detector can only be exercised here, where the
# proposed workflow body IS the checked-out one.
- name: Test inline-test detector
run: node scripts/test-pr-triage-detect.mjs
test:
name: test (${{ matrix.toolchain }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
toolchain: [stable, beta]
# Beta is informational: an upstream beta regression should warn, not block merges.
continue-on-error: ${{ matrix.toolchain == 'beta' }}
# Postgres for the DB-backed integration tests (#[sqlx::test] provisions an
# isolated database per test off this connection). The health-check gate keeps
# `cargo test` from racing a not-yet-ready server.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gitlawb_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: ${{ matrix.toolchain }}
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: ${{ matrix.toolchain }}
run: cargo clippy --workspace --all-targets -- -D warnings
- name: cargo test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/gitlawb_test
run: cargo test --locked --workspace
build-release:
name: build --release
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: release
- name: cargo build --release
run: cargo build --locked --release --workspace
audit:
name: cargo audit
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: audit-0.22.2
- name: Install cargo-audit (pinned)
# Pin the scanner on this BLOCKING gate: an unpinned `cargo install`
# floats to the latest release, so a regressed cargo-audit could red CI
# repo-wide with no code change. Advisory-DB freshness is fetched at scan
# time, independent of the binary, so this does not weaken what the gate
# catches. The weekly Scheduled Audit (audit-schedule.yml) deliberately
# floats to latest as the drift canary; when it shows a newer cargo-audit
# behaving differently, bump this version (and the cache key above) as a
# deliberate maintainer action. If 0.22.2 is ever yanked from crates.io
# this step fails loud until the pin is bumped.
run: |
set -euo pipefail
cargo install --locked --version 0.22.2 cargo-audit
# Assert the pin took effect so an accidental future unpin (or a drift
# off 0.22.2) fails loudly here instead of silently running a different
# scanner. `cargo audit --version` prints "cargo-audit-audit 0.22.2".
installed="$(cargo audit --version)"
echo "cargo-audit installed: $installed"
echo "$installed" | grep -qE '(^| )0\.22\.2($| )' || {
echo "::error::cargo-audit is not the pinned 0.22.2 (got: $installed)"
exit 1
}
# Hard-fail gate. Suppressions live in .cargo/audit.toml (read automatically
# from the repo root), each with no available upstream fix. A green check
# here means "no NEW advisories", not "advisory-clean": surface the active
# ignore list so a reviewer sees exactly what is being accepted.
- name: Show active audit ignores
run: |
{
echo '### cargo audit: accepted advisories (.cargo/audit.toml)'
echo '```toml'
sed -n '/^\[advisories\]/,$p' .cargo/audit.toml
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: cargo audit
run: cargo audit
msrv:
name: MSRV (Rust 1.91)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.91
with:
toolchain: "1.91"
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: msrv
- name: cargo check on MSRV
run: cargo check --locked --workspace --all-targets
docker-build:
name: Docker build smoke test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build image
run: docker build -t gitlawb-node:ci-test .
- name: Smoke test --version
run: docker run --rm gitlawb-node:ci-test --version
test-windows:
name: test (windows, non-blocking)
runs-on: windows-latest
timeout-minutes: 45
# Non-blocking, mirroring the informational `beta` lane above. We ship `gl`
# and `git-remote-gitlawb` to Windows users (release.yml) with zero Windows
# test coverage, so run their suites here to make Windows-only regressions
# VISIBLE on the PR without gating merge (#228). These two are the client
# CLIs and depend on no database, so no Postgres service is needed (service
# containers do not run on windows-latest regardless). `git` is preinstalled
# on windows-latest, which the real_git_fetch harness relies on.
continue-on-error: true
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: windows
- name: cargo test (shipped Windows crates)
run: cargo test --locked -p gl -p git-remote-gitlawb
# gitlawb-core is embedded by every consumer (gl, git-remote-gitlawb, the node
# daemon), so it must stay lean. This gate fails if gitlawb-core's normal
# (non-dev, non-build) dependency tree gains any crate not on the allowlist in
# ci/gitlawb-core-allowed-deps.txt. Exhaustive-by-construction: a new heavy
# dependency reds CI whether or not anyone thought to ban it, which a denylist
# cannot do. Regen instructions live in the allowlist header.
core-deps-purity:
name: gitlawb-core dependency purity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: core-deps-purity
- name: Check gitlawb-core dependency allowlist
run: bash scripts/check-gitlawb-core-deps.sh
# release-please only rewrites a crate's version when the line carries the
# `# x-release-please-version` annotation AND the manifest is listed under
# extra-files in release-please-config.json. Miss either half and the crate
# is skipped silently at release time, freezing at its creation version:
# icaptcha-client was added as a workspace member after the config was last
# written and sat at 0.4.0 through four releases before anyone noticed.
# Exhaustive-by-construction, like the allowlist gate above: the required set
# is derived from `cargo metadata`, so a newly added crate reds CI whether or
# not its author knew this gate existed.
release-versioning:
name: release versioning coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
with:
key: release-versioning
- name: Check every workspace crate is release-managed
run: bash scripts/check-release-versioning.sh
run: cargo test --workspace
-342
View File
@@ -1,342 +0,0 @@
name: PR Triage
# Runs on pull_request_target, not pull_request: fork PRs get a read-only
# GITHUB_TOKEN under pull_request regardless of the permissions below, so the
# label/comment writes 403. This job never checks out or runs PR head code (it
# only reads context.payload and the files API), so pull_request_target is safe.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: pr-triage-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
name: Quality-signal triage
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Label and guide
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const prNumber = pr.number;
// Trusted authors bypass triage entirely.
const trusted = ["OWNER", "MEMBER", "COLLABORATOR"];
if (trusted.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is trusted; skipping triage.`);
return;
}
// Labels this workflow owns. Anything here may be added or removed each run.
const MANAGED = ["needs-issue", "needs-description", "needs-tests", "workflow-change"];
const want = new Set();
const body = pr.body || "";
// Linked issue: look for "closes/fixes/resolves #N" or any bare #N reference.
const hasLinkedIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\b[^\n]*#\d+/i.test(body) ||
/#\d+/.test(body);
if (!hasLinkedIssue) want.add("needs-issue");
// Description substance: strip HTML comments and markdown headings, then measure.
const stripped = body
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/^#+.*$/gm, "")
.trim();
if (stripped.length < 50) want.add("needs-description");
// File-based signals.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNumber, per_page: 100,
});
const names = files.map(f => f.filename);
const isFork = pr.head.repo && pr.head.repo.full_name !== pr.base.repo.full_name;
const touchesWorkflows = names.some(n => n.startsWith(".github/workflows/"));
if (isFork && touchesWorkflows) want.add("workflow-change");
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
// Async tests use a namespaced attribute (#[tokio::test], #[async_std::test],
// possibly with args like #[tokio::test(flavor = ...)]) rather than the bare
// #[test]. Match only on added lines, with the attribute at the start of the
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
// or lexer: needs-tests is advisory, not a merge gate.
//
// The final path component may also carry `test` as an underscore-delimited
// segment, so third-party harnesses land without enumerating them one by one
// (#[test_case(1)], #[wasm_bindgen_test]). It is deliberately a segment and
// not a substring: a substring would also match #[contest] / #[latest], and a
// false positive here SUPPRESSES needs-tests rather than adding it. This
// heuristic has to fail loud, because a wrongly labeled PR gets corrected by
// the author while a wrongly cleared one is invisible. That trade costs the
// no-underscore harness names (#[rstest]), which stay unmatched on purpose.
//
// The segment classes are [A-Za-z0-9]+ rather than \w+ on purpose, so that `_`
// is only ever the literal delimiter. \w+ contains `_`, which makes the repeat
// ambiguous and backtracks exponentially: on this workflow's pull_request_target
// trigger f.patch is fork-controlled, so `#[test` followed by a long `_a` run is
// a permissionless stall of the triage job.
// Rust allows whitespace, line breaks, and comments between the
// attribute path and ] or (. A flat regex cannot decide that
// boundary: `//` runs to end of line, and block comments NEST, which
// is beyond regular languages — every enumeration of comment
// spellings so far has either dropped a legal separator (regressing
// real tests into needs-tests) or let unrelated patch records
// complete each other (clearing needs-tests from a testless PR, the
// invisible direction). So the closer is found by a token-aware
// scan instead: it consumes horizontal whitespace, `//`-to-newline,
// and depth-counted `/* */` runs, and accepts only when the first
// real token after the attribute path is `]` or `(`.
//
// Association is positional: the scan starts on the attribute's own
// added line and may continue ONLY through the immediately following
// added lines (a context line, hunk header, or removal breaks the
// chain). Unrelated `]`/`(` lines elsewhere in the patch can never
// complete a path they do not adjoin. The continuation is bounded;
// past the bound the scan gives up and reports NO inline test, which
// fails toward the loud label rather than the silent clearance.
//
// The detector body is fenced by the TRIAGE_DETECTOR markers so
// scripts/test-pr-triage-detect.mjs can extract and run it against
// the committed case matrix. Keep the fenced block self-contained.
// TRIAGE_DETECTOR_BEGIN
const TEST_ATTR_PATH =
"(?:cfg[ \\t]*\\([ \\t]*test[ \\t]*\\)|(?:::[ \\t]*)?(?:[\\w-]+[ \\t]*::[ \\t]*)*(?:r#)?(?:[A-Za-z0-9]+_)*test(?:_[A-Za-z0-9]+)*)";
// Anchored to the start of one added line; group 1 is whatever
// follows the path on that line, handed to the separator scan.
const TEST_ATTR_LINE = new RegExp(
"^[ \\t]*#\\[[ \\t]*" + TEST_ATTR_PATH + "([\\s\\S]*)$"
);
// An attribute split across more added lines than this is not a
// spelling anyone writes; refusing to scan further keeps the walk
// linear in the patch and fails toward the loud label.
const MAX_ATTR_CONTINUATION_LINES = 16;
// segs[0] is the remainder of the attribute's own line; each later
// entry is the text of one immediately following added line. True
// only when the first non-separator token across them is ] or (.
// Single forward pass, no backtracking: fork-controlled input.
function attrCloserFollows(segs) {
let depth = 0; // open block-comment nesting carried across lines
for (const seg of segs) {
let i = 0;
while (i < seg.length) {
if (depth > 0) {
if (seg.startsWith("/*", i)) { depth += 1; i += 2; }
else if (seg.startsWith("*/", i)) { depth -= 1; i += 2; }
else { i += 1; }
continue;
}
const c = seg[i];
if (c === " " || c === "\t") { i += 1; continue; }
if (seg.startsWith("//", i)) break; // comment to end of line
if (seg.startsWith("/*", i)) { depth = 1; i += 2; continue; }
return c === "]" || c === "(";
}
// Line exhausted inside separators; the newline is itself a
// separator, so continue on the next added line.
}
return false; // out of adjoined lines (or bound hit): stay loud
}
// Before the candidate scan, a pre-pass walks added patch lines
// tracking Rust lexical state: raw strings (r#*", br#*", cr#*"),
// block comments (/* */ with nesting), regular strings ("...",
// which can span newlines), and line comments (//). An added line
// that starts inside any of these is skipped as a candidate,
// because a #[test_case] appearing there is lexical non-code, not
// a real test attribute. State is only tracked across consecutive
// added lines and reset at any break (context line, removal,
// hunk header): context lines carry a partial view of the file,
// so a " or r#" or /* there might be inside a string that opened
// before the hunk's visible window, and treating it as an opener
// would mark every later added line as non-code (a false negative
// on a real #[test]). The tradeoff is that a non-code region
// opened by a context line is invisible: an added #[test] inside
// an existing raw string or block comment is scanned as code and
// may match (a false positive, silent direction). This is accepted
// because (1) the old detector had the same behavior, (2) the
// pattern is rare, and (3) needs-tests is advisory, not a merge
// gate. Scanning context lines to close this gap would re-introduce
// the false negative, which affects the far more common case of a
// context line carrying a stray " from a string that opened before
// the visible window. Single forward pass, O(n) in patch length.
function patchAddsInlineTest(patch) {
const lines = patch.split("\n");
const nonCodeStart = new Set();
let rawHashes = -1; // -1 = not in raw string; >=0 = # count
let blockDepth = 0;
let inString = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const prefix = line[0];
// Only scan added lines. Context and removal lines carry
// a partial view of the file: a " or r#" or /* on a context
// line might be inside a string that opened before the
// hunk's visible window, and treating it as an opener would
// mark every later added line as non-code (a false negative
// on a real #[test]). So lexical state is only tracked across
// consecutive added lines and reset at any break. A non-code
// region opened by a context line is invisible; the candidate
// is still scanned, which fails toward the loud label.
if (prefix !== "+") {
rawHashes = -1; blockDepth = 0; inString = false;
continue;
}
const text = line.slice(1);
if (rawHashes >= 0 || blockDepth > 0 || inString)
nonCodeStart.add(i);
let j = 0;
while (j < text.length) {
if (rawHashes >= 0) {
if (text[j] === '"') {
let h = 1;
while (h <= rawHashes && text[j + h] === "#") h++;
if (h > rawHashes) { rawHashes = -1; j += h; continue; }
}
j += 1; continue;
}
if (blockDepth > 0) {
if (text.startsWith("/*", j)) { blockDepth += 1; j += 2; }
else if (text.startsWith("*/", j)) { blockDepth -= 1; j += 2; }
else { j += 1; }
continue;
}
if (inString) {
if (text[j] === "\\") { j += 2; continue; }
if (text[j] === '"') { inString = false; j += 1; continue; }
j += 1; continue;
}
// Recognize Rust char and byte-char literals ('X', '\X',
// '\u{...}', b'X') so their contents never open string or
// comment state. A char literal has a closing ' within a
// bounded distance; a lifetime or label ('a, 'static,
// 'label:) does not, so it falls through as a regular char.
// This is not a skip-to-next-' rule: the lookahead is 2-4
// chars, or bounded to 20 for \u escapes (at most 6 hex
// digits), never unbounded.
if (text[j] === "'") {
let k = j + 1;
if (text[k] === "\\") {
k += 1;
if (text[k] === "u" && text[k + 1] === "{") {
const uBound = k + 20;
while (k < uBound && text[k] && text[k] !== "}") k++;
if (text[k] === "}") k++;
} else {
k += 1;
}
} else {
k += 1;
}
if (text[k] === "'") { j = k + 1; continue; }
// lifetime or label: just skip the apostrophe
}
if (text[j] === '"') { inString = true; j += 1; continue; }
if (text.startsWith("//", j)) break;
if (text.startsWith("/*", j)) { blockDepth = 1; j += 2; continue; }
const prev = j > 0 ? text[j - 1] : "";
const isIdent = /[A-Za-z0-9_]/.test(prev);
if (!isIdent && text[j] === "r") {
let k = j + 1, h = 0;
while (text[k + h] === "#") h++;
if (text[k + h] === '"') {
rawHashes = h; j = k + h + 1; continue;
}
}
if (!isIdent && (text[j] === "b" || text[j] === "c") && text[j + 1] === "r") {
let k = j + 2, h = 0;
while (text[k + h] === "#") h++;
if (text[k + h] === '"') {
rawHashes = h; j = k + h + 1; continue;
}
}
j += 1;
}
}
for (let i = 0; i < lines.length; i++) {
if (lines[i][0] !== "+") continue;
if (nonCodeStart.has(i)) continue;
const m = TEST_ATTR_LINE.exec(lines[i].slice(1));
if (m === null) continue;
const segs = [m[1]];
for (
let j = i + 1;
j < lines.length &&
lines[j][0] === "+" &&
segs.length <= MAX_ATTR_CONTINUATION_LINES;
j++
) {
segs.push(lines[j].slice(1));
}
if (attrCloserFollows(segs)) return true;
}
return false;
}
// TRIAGE_DETECTOR_END
const addsInlineTest = files.some(
f => f.filename.endsWith(".rs") && !!f.patch && patchAddsInlineTest(f.patch)
);
const touchedTests =
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
if (changedRust && !touchedTests) want.add("needs-tests");
// Reconcile labels: add wanted managed labels, remove managed labels no longer wanted.
const current = (await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber,
})).data.map(l => l.name);
const toAdd = [...want].filter(l => !current.includes(l));
const toRemove = MANAGED.filter(l => current.includes(l) && !want.has(l));
if (toAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd });
}
for (const name of toRemove) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name })
.catch(() => {}); // ignore if already gone
}
// Guidance comment: only for advisory signals, and only once.
const advisory = ["needs-issue", "needs-description", "needs-tests"].filter(l => want.has(l));
if (!advisory.length) return;
const MARKER = "<!-- gitlawb-triage -->";
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
if (comments.some(c => c.body && c.body.includes(MARKER))) {
core.info("Guidance comment already present; not reposting.");
return;
}
const reasons = {
"needs-issue": "Link the issue this addresses (`Closes #123`). For protocol changes, open an issue first.",
"needs-description": "Add a short summary of what changes and why: the template's Summary and Motivation sections.",
"needs-tests": "This changes Rust source but no tests changed. Tests are required for fixes and strongly encouraged for features.",
};
const lines = advisory.map(l => `- ${reasons[l]}`).join("\n");
const comment = [
MARKER,
"Thanks for the contribution. A couple of things will help us review this faster:",
"",
lines,
"",
"See [CONTRIBUTING.md](https://github.com/Twigpine/node/blob/main/CONTRIBUTING.md). Update the PR and these notes will clear automatically.",
].join("\n");
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: comment });
File diff suppressed because it is too large. Load diff
-40
View File
@@ -1,40 +0,0 @@
name: Stale PRs
on:
schedule:
- cron: "30 1 * * *" # daily at 01:30 UTC
workflow_dispatch:
permissions:
contents: read
pull-requests: write
issues: write
jobs:
stale:
name: Flag and close ignored PRs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
days-before-stale: 14
days-before-close: 7
# Act on PRs only; never on issues.
days-before-issue-stale: -1
days-before-issue-close: -1
# Only PRs the triage step flagged as missing a real description.
# needs-issue is advisory-only (linking an issue is a SHOULD, and many
# legitimate small PRs have none) so it does not drive auto-close.
only-pr-labels: "needs-description"
stale-pr-label: "stale"
exempt-pr-labels: "workflow-change,security,pinned"
remove-stale-when-updated: true
stale-pr-message: >
This PR has been inactive for 14 days and is still missing a description.
It will be closed in 7 days if there's no update. Push a change or leave a
comment to keep it open. No hard feelings, you can reopen anytime.
close-pr-message: >
Closing due to inactivity. Reopen whenever you're ready to update it. See
CONTRIBUTING.md for what helps us review quickly.
ascending: true
-5
View File
@@ -1,7 +1,6 @@
/target
**/*.rs.bk
Cargo.lock.bak
dist/
# Local node data
/data
@@ -22,7 +21,3 @@ keys/
# Logs
*.log
.openclaude-profile.json
# Per-operator Fly config (node1 prod config is pulled from Fly, not tracked)
infra/fly/node1.fly.toml
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.7.1"
".": "0.3.8"
}
-44
View File
@@ -1,44 +0,0 @@
# Contributor guide for coding agents
This file carries the rules a patch author cannot recover from the code alone, for humans and coding agents alike. For build, test, and PR basics, read `CONTRIBUTING.md` first.
## Authorization invariants (the rules PRs most often break)
Every repo-scoped endpoint must bind the caller to an authorization decision before serving or mutating anything. The `known_ungated` allowlist in `authz_guard::every_repo_scoped_handler_is_gated` is empty on main: `list_webhooks`, `list_replicas`, and `list_protected_branches` are gated (`list_webhooks` with read-visibility then owner; the other two with `authorize_repo_read`). Do not re-open those surfaces as exceptions.
- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated against the repo owner. Three gate forms are in use and all are recognized: webhooks and merges call `require_repo_owner` (`crates/gitlawb-node/src/api/mod.rs`), visibility uses its module-local `require_owner`, and protected branches compare inline with `did_matches`. Use the form the surrounding module already uses.
- Not every write is owner-only, and that is by design: `star_repo`/`unstar_repo` bind to the signer (they still require repo read access), `register_replica`/`unregister_replica` bind to the replica's own DID, the bounty actions (claim, submit, approve, cancel, dispute) have their own multi-party rules, and closing a PR or issue is owner-or-author. Do not add owner gates to these.
- Content-serving reads (blobs, trees, raw files) gate through `authorize_repo_read` with the specific path being served, so a withheld subtree is denied even on an otherwise-public repo; repo-level reads (listings) pass `"/"` by the helper's own contract. The source scans below cannot check this argument's value, so review will.
- A route that reaches repo data through a global id, with no repo in its path, or through an extractor shape the scans do not recognize still needs the same gate; the scans cannot see those.
- Read-surface denials must not reveal existence: a caller who may not read a repo (or a withheld subtree) gets the same 404 as a missing repo, never a 403. (Owner-gated mutations currently return 403 after the repo lookup; do not widen that shape to read paths.)
- Two tests in module `authz_guard` (`crates/gitlawb-node/src/api/mod.rs`) scan handler sources and fail on a repo-scoped handler without a recognized gate: `every_in_scope_mutation_has_its_gate` and `every_repo_scoped_handler_is_gated`. If they fail on your handler, add the gate. The second test's `known_ungated` allowlist is empty; never add an entry to it without an open issue and review.
## Adding or changing routes
- A new gated handler needs tests proving its route-specific gate fires: exercise an unauthorized authenticated caller and an anonymous caller where applicable, assert the exact denial status each way, and assert the body leaks nothing. The non-owner tests in `crates/gitlawb-node/src/test_support.rs` show the shape for owner-gated routes.
- A new mutation handler also gets a row in `every_in_scope_mutation_has_its_gate` naming its gate type; the per-handler row is the point of that test.
- A table-driven deny suite covering every deny-bearing route is in review (#194, #195). If it is present in your checkout, add a probe row there for any new deny-bearing route.
## Verifying signatures and certificates
- Derive the verifying key from something outside the artifact you are checking: the node's own DID, a pinned or configured value, a peers lookup. A key read from a field of the object being verified (`Did::from_str(&cert.node_did)`) only proves the artifact is self-consistent, and identities here are permissionless, so anyone can mint a keypair, name it in the artifact, self-sign, and be told it is valid. A mismatch against that anchor has to fail the result, not log a warning.
- A handler whose correct answer is "valid" needs a positive case driven by a trusted, independently anchored artifact, and a rejection case for a well-formed, fully populated forged artifact. Invalid-only suites miss an implementation that rejects everything; a forged artifact that still returns valid is the regression that matters.
- The fields a signature covers are a versioned format. Adding one invalidates every artifact already signed, so give the payload a version, keep a path that verifies the older form, and add a test that verifies an artifact signed before your change.
## Removing a serving path
When a fix removes a path that used to serve content, the same PR must add a test asserting the removed path now denies. The deletion is what turns that test green; without it the path can quietly come back.
## Database migrations
Schema changes are code-defined in `MIGRATIONS` (`crates/gitlawb-node/src/db/mod.rs`). Always append a new versioned entry; never edit an entry that has merged (operators deploy from main). Test runs build the schema from scratch, so editing an applied version stays green in CI while breaking every existing deployment.
## Client behavior (`gl`, `git-remote-gitlawb`)
When a node denies a request, surface the denial to the user. Never render a denial as an empty list or a silent success; that turns an authorization boundary into invisible data loss.
## Toolchain and CI reality
- MSRV is Rust 1.91 (`rust-version` in the workspace `Cargo.toml`). The MSRV CI job runs `cargo check --workspace --all-targets` on exactly 1.91, so check that an API you rely on is stable there; the full test suite runs on stable.
- The PR workflow runs these jobs on every PR: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (against Postgres), a release build, `cargo audit`, the MSRV check, and a Docker build. The `main` branch ruleset currently requires one approving review but does not require these checks to pass before merge; a failing job should block review approval in practice, but nothing enforces that mechanically today.
- Conventional commit titles (`feat:`, `fix:`, `docs:`, ...) are required by convention and drive releases, but no CI job checks them; a bad title fails review, not the pipeline.
-146
View File
@@ -1,146 +0,0 @@
# Changelog
## [0.7.1](https://github.com/Gitlawb/node/compare/v0.7.0...v0.7.1) (2026-08-03)
### Bug Fixes
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([99b2b9a](https://github.com/Gitlawb/node/commit/99b2b9a16662761a3959a2941798e6823621bda5))
* **ci:** make the docker dependency cache work and unbreak Dockerfile.bins ([a49e04d](https://github.com/Gitlawb/node/commit/a49e04ddcd23f3b666421486ae60c3c4329e2242)), closes [#265](https://github.com/Gitlawb/node/issues/265) [#266](https://github.com/Gitlawb/node/issues/266)
* **db:** pin profile DID matching with lone non-key fixture and boundary matrix ([b4016dc](https://github.com/Gitlawb/node/commit/b4016dce9d1fe450603173f9d92bd880127620fa)), closes [#298](https://github.com/Gitlawb/node/issues/298)
* **node:** add a shared owner/name slug validator ([#272](https://github.com/Gitlawb/node/issues/272)) ([51d82de](https://github.com/Gitlawb/node/commit/51d82ded57a776d9c7e2fe65895efdbf9e227dc8))
* **node:** bound the slug owner by NAME_MAX, not the DID limit ([#272](https://github.com/Gitlawb/node/issues/272)) ([27b0495](https://github.com/Gitlawb/node/commit/27b0495a6f7edeecddbe62b0f075a7e3b1ef5053))
* **node:** fail a sync row terminally when a non-directory holds the owner path ([2e664b7](https://github.com/Gitlawb/node/commit/2e664b7885036ee72ce175af95bb567b18222583))
* **node:** make a stalled sync queue visible, and fix two test claims ([9aa4c17](https://github.com/Gitlawb/node/commit/9aa4c17aefd1885d134b1334b54551366cdd8ef0))
* **node:** move the attempted_at migration to v17, clear of two open branches ([fbc5868](https://github.com/Gitlawb/node/commit/fbc5868bd16946ea184dad26abcdb0e89d68e99a))
* **node:** prove the mirror path is inside repos_dir before running git ([#272](https://github.com/Gitlawb/node/issues/272)) ([3ca9473](https://github.com/Gitlawb/node/commit/3ca94739ed7b90e4c265a0947025dc3b50e7583b))
* **node:** reject a malformed slug at the notify boundary ([#272](https://github.com/Gitlawb/node/issues/272)) ([c587a40](https://github.com/Gitlawb/node/commit/c587a40a508acea9f74c3468d42ff037397c75d0))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([1964f88](https://github.com/Gitlawb/node/commit/1964f88378fe6942ffca5836de3a092b5b8f67b4))
* **node:** reset a peer's reachability gate when an announce changes its http_url ([#270](https://github.com/Gitlawb/node/issues/270)) ([e2560ee](https://github.com/Gitlawb/node/commit/e2560ee46c28e4e966d87867a86dc4bad4ca7a3a))
* **node:** stamp every dequeued sync row so a stuck one yields the batch ([6e64d0d](https://github.com/Gitlawb/node/commit/6e64d0d0a7fdd38ef3a56820c1808dd7c9bbd658))
* **node:** stop a peer-supplied repo slug from escaping repos_dir ([#272](https://github.com/Gitlawb/node/issues/272)) ([c70b1df](https://github.com/Gitlawb/node/commit/c70b1df42d5c3f70ec4c272886c85fcc16ed92d1))
* **node:** validate the queued slug before the worker derives a path ([#272](https://github.com/Gitlawb/node/issues/272)) ([9f00e24](https://github.com/Gitlawb/node/commit/9f00e24aec217e4b8256dd437cff4741b1478b96))
* **review:** correct two comment claims and guard two more wrong shapes ([#270](https://github.com/Gitlawb/node/issues/270)) ([811ad50](https://github.com/Gitlawb/node/commit/811ad504bc268c8e9cddac93822adcd00bfe9ff8))
* **review:** stop the dequeue overclaiming, and pin the starvation premise ([457be11](https://github.com/Gitlawb/node/commit/457be11d728a2a34775cf96be2bc5af9a58cb250))
* strict DID matching in get_profile ([acb4714](https://github.com/Gitlawb/node/commit/acb47145beec8ce7b20b7a2479f7a55ed68e2a89))
* strict DID matching in get_profile ([13b565b](https://github.com/Gitlawb/node/commit/13b565bcac09f075a7fd555a6e9acdd27de6c7bb))
* target stored did on profile update aliases ([9836c3a](https://github.com/Gitlawb/node/commit/9836c3a21bc96a2ce95cb1fd12bd84b22142787d))
## [0.7.0](https://github.com/Gitlawb/node/compare/v0.6.0...v0.7.0) (2026-07-22)
### Features
* **gl:** actually verify certificate signatures in gl cert show ([b0a2ed8](https://github.com/Gitlawb/node/commit/b0a2ed830821b5a77b0dafdcf5ce4b1ad904ec25))
* **gl:** doctor warns when a shell alias shadows the gl binary ([0765c42](https://github.com/Gitlawb/node/commit/0765c42feff47b29054b9a277881a7cd5bfd9135))
### Bug Fixes
* **gl:** address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias ([3fa38dc](https://github.com/Gitlawb/node/commit/3fa38dce4e435ef017f607d355266c2892161f8d))
* **gl:** anchor cert --verify to a trusted issuer; pin canonical payload form ([c681af7](https://github.com/Gitlawb/node/commit/c681af70dba3a76b11795d324f2ca0fecf7e42fd))
* **gl:** detect gitlawb remotes beyond origin in gl status ([2d88937](https://github.com/Gitlawb/node/commit/2d88937fbabf21013f28debb4d4f93270a4bb8cd))
* **gl:** doctor treats a reachable local node as configuration, not failure ([94c61f7](https://github.com/Gitlawb/node/commit/94c61f79421fa26ecc1bdf7c255e0c48e66a2048))
* **gl:** make gl init's push guidance match the repo's actual state ([cb42a48](https://github.com/Gitlawb/node/commit/cb42a4830d0d070165e2212f316d009ec716dbee))
* warn about the oh-my-zsh gl alias at install time ([e3df51d](https://github.com/Gitlawb/node/commit/e3df51d76c0275fde33954ff0af0aab95fd0cb7d))
## [0.6.0](https://github.com/Gitlawb/node/compare/v0.5.1...v0.6.0) (2026-07-22)
### Features
* **icaptcha-client:** solve the iCaptcha proof-of-work on answer ([#181](https://github.com/Gitlawb/node/issues/181)) ([c98b503](https://github.com/Gitlawb/node/commit/c98b503f90cade54ce9c588bd2373a4e3486f2fc))
### Bug Fixes
* **core:** honor wildcard capabilities in Ucan::can, matching is_attenuated_by ([#200](https://github.com/Gitlawb/node/issues/200)) ([b6877a6](https://github.com/Gitlawb/node/commit/b6877a65981dbf41c4a7838971eb5c826c7fcaf2))
* **gl:** point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo ([#198](https://github.com/Gitlawb/node/issues/198)) ([98d728c](https://github.com/Gitlawb/node/commit/98d728c325f68f0ee4a62e473dc484b7dd627504))
* **node:** carry full owner DID on ref-update wire event ([#144](https://github.com/Gitlawb/node/issues/144)) ([#145](https://github.com/Gitlawb/node/issues/145)) ([69a62e5](https://github.com/Gitlawb/node/commit/69a62e51c2d2f090f9cae88f4d8ce3b9017b7149))
* **node:** gate /hooks and sibling read surfaces leaking private repo metadata ([#94](https://github.com/Gitlawb/node/issues/94)) ([#113](https://github.com/Gitlawb/node/issues/113)) ([ee8d7c0](https://github.com/Gitlawb/node/commit/ee8d7c03b26a0b678cc90b8801aa340fe81da700))
* strip Cf bidi/format controls in terminal-bound error sanitizers ([#183](https://github.com/Gitlawb/node/issues/183)) ([#188](https://github.com/Gitlawb/node/issues/188)) ([b07d41e](https://github.com/Gitlawb/node/commit/b07d41ea97569d79d3f545ae5d43c373cd6f0f33))
## [0.5.1](https://github.com/Gitlawb/node/compare/v0.5.0...v0.5.1) (2026-07-10)
### Bug Fixes
* **bounties:** add tests for claim_bounty repo-read gate ([#160](https://github.com/Gitlawb/node/issues/160)) ([#169](https://github.com/Gitlawb/node/issues/169)) ([6bafaa6](https://github.com/Gitlawb/node/commit/6bafaa6dc5f05c0dcd61c708397afddbcf8c2e3f))
* **deps:** bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 ([#162](https://github.com/Gitlawb/node/issues/162)) ([#163](https://github.com/Gitlawb/node/issues/163)) ([67ad2b8](https://github.com/Gitlawb/node/commit/67ad2b876c8d9a336219d1016968de7a88fc4e75))
* **node,git:** bound a hung served git with a total-duration timeout ([#62](https://github.com/Gitlawb/node/issues/62)) ([#165](https://github.com/Gitlawb/node/issues/165)) ([cd67718](https://github.com/Gitlawb/node/commit/cd67718f49ec38726a40f6bcf36f539ccdb42969))
* **node:** bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth ([#147](https://github.com/Gitlawb/node/issues/147)) ([#149](https://github.com/Gitlawb/node/issues/149)) ([6b5e5bc](https://github.com/Gitlawb/node/commit/6b5e5bc7aee00a2d03295d3620df1ee4d8c024a2))
* **node:** gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes ([#82](https://github.com/Gitlawb/node/issues/82)) ([#161](https://github.com/Gitlawb/node/issues/161)) ([d00d89a](https://github.com/Gitlawb/node/commit/d00d89ae5be992d1f63e95b714ae1bd3735e8457))
* **node:** rate-limit repo/agent creation per client IP to stop DID-farm spam floods ([#180](https://github.com/Gitlawb/node/issues/180)) ([dfcaa22](https://github.com/Gitlawb/node/commit/dfcaa22b23ec91be4c75926956aa994ca89de8d5))
* **release:** build aarch64-musl natively on arm64 runners, replace retired macos-13 ([#155](https://github.com/Gitlawb/node/issues/155)) ([6cff528](https://github.com/Gitlawb/node/commit/6cff5286b436fdee44ad881999e2a5f4bdba18f9))
* **visibility:** gate repo-scoped read surfaces on visibility ([#120](https://github.com/Gitlawb/node/issues/120)) ([#157](https://github.com/Gitlawb/node/issues/157)) ([26bc3f6](https://github.com/Gitlawb/node/commit/26bc3f69870aa77e43c0d92115a5aa59555b7d88))
## [0.5.0](https://github.com/Gitlawb/node/compare/v0.4.0...v0.5.0) (2026-07-05)
### Features
* **gl:** sanctioned iCaptcha client flow + secure git lifecycle ([#138](https://github.com/Gitlawb/node/issues/138)) ([06388ec](https://github.com/Gitlawb/node/commit/06388ec26aa29d356ae311276fdb91be054e9ecc))
### Bug Fixes
* **gl:** sign the CLI's /ipfs/pins reads under the [#134](https://github.com/Gitlawb/node/issues/134) auth gate ([#146](https://github.com/Gitlawb/node/issues/146)) ([20d6848](https://github.com/Gitlawb/node/commit/20d6848846b3a988d604208833167a528b7d8820))
* **node,git-remote:** gate receive-pack advertisement, sign client fetch/push ([#119](https://github.com/Gitlawb/node/issues/119)) ([6f36fc0](https://github.com/Gitlawb/node/commit/6f36fc07b8e10a650c5948b269feac1cb25cae2a))
* **node,gossip:** route gossip HTTP through the no-redirect client ([#93](https://github.com/Gitlawb/node/issues/93)) ([#140](https://github.com/Gitlawb/node/issues/140)) ([563c456](https://github.com/Gitlawb/node/commit/563c456803bf3e958d63869db424b3940472bc3d))
* **node:** close two spam-vector root causes (trust upsert + ungated push) ([#152](https://github.com/Gitlawb/node/issues/152)) ([2df6ff9](https://github.com/Gitlawb/node/commit/2df6ff9d30de62f754fa41473e85db316021718e))
* **node:** gate GET /ipfs/{cid} on reachable allowed-set, not deny-set ([#126](https://github.com/Gitlawb/node/issues/126)) ([#133](https://github.com/Gitlawb/node/issues/133)) ([466a550](https://github.com/Gitlawb/node/commit/466a550915edd711856ef32035f9f474e2577c4f))
* **node:** gate the ref-updates feeds on read visibility ([#112](https://github.com/Gitlawb/node/issues/112), [#114](https://github.com/Gitlawb/node/issues/114)) ([#143](https://github.com/Gitlawb/node/issues/143)) ([4891db3](https://github.com/Gitlawb/node/commit/4891db38892663326ee0c1417a2db931988be4b5))
* **node:** prefer canonical repo row over mirror row in get_repo ([#124](https://github.com/Gitlawb/node/issues/124)) ([#141](https://github.com/Gitlawb/node/issues/141)) ([6c95592](https://github.com/Gitlawb/node/commit/6c95592d188222ac3446dc23ef8d9befbf82ad6f))
* **remote:** include HTTP error response body ([#137](https://github.com/Gitlawb/node/issues/137)) ([09a0cb2](https://github.com/Gitlawb/node/commit/09a0cb23b9f284ccbd69aca6958b70671f3bfb46))
* **repos:** log the cause when repo create fails ([#103](https://github.com/Gitlawb/node/issues/103)) ([2620e97](https://github.com/Gitlawb/node/commit/2620e973e3cd4835ed42ddd4adcd8183b5b3080e))
## [0.4.0](https://github.com/Gitlawb/node/compare/v0.3.9...v0.4.0) (2026-06-30)
### Features
* agent profiles (display name, bio, avatar, social links) ([#23](https://github.com/Gitlawb/node/issues/23)) ([09a3397](https://github.com/Gitlawb/node/commit/09a339745eca40a2567d911e947e3fc7426fc621))
* **db:** versioned schema migrations with idempotent backfill ([#21](https://github.com/Gitlawb/node/issues/21)) ([927e4d0](https://github.com/Gitlawb/node/commit/927e4d0cfb4ea11dca6930780939afaa067797ba))
* encrypted replication for private subtrees (B1/B2/B3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#36](https://github.com/Gitlawb/node/issues/36)) ([5ff7af8](https://github.com/Gitlawb/node/commit/5ff7af84fa21fab53a12dfa04a0e7fb7e7d672e6))
* **git-remote-gitlawb:** add --version and --help flags ([#30](https://github.com/Gitlawb/node/issues/30)) ([3a401eb](https://github.com/Gitlawb/node/commit/3a401eb0792f9c5f5a10de6c16861655ab3836e0))
* **gitlawb-attest:** External Attestation v1 for ref-update certs ([#20](https://github.com/Gitlawb/node/issues/20)) ([924bccd](https://github.com/Gitlawb/node/commit/924bccd8e53e9be2dc9d6d4e4f1376952d6462bb))
* **node:** blind recipient identities at rest and gate B1 by repo readability ([#40](https://github.com/Gitlawb/node/issues/40)) ([abdc775](https://github.com/Gitlawb/node/commit/abdc7757708d2bbb2bfda99bb65d50756142a42e))
* **node:** enforce per-route authorization across the REST and GraphQL surface ([#87](https://github.com/Gitlawb/node/issues/87)) ([2202b00](https://github.com/Gitlawb/node/commit/2202b0097fab6976ab366a2cdc385f1146a72f86))
* **node:** graceful shutdown + Prometheus metrics endpoint ([#22](https://github.com/Gitlawb/node/issues/22)) ([2ce4da9](https://github.com/Gitlawb/node/commit/2ce4da9cc5a4791e8ae5a1cd90270b67f60d9ec3))
* **node:** iCaptcha proof-of-intelligence gate on create_repo + register ([#108](https://github.com/Gitlawb/node/issues/108)) ([adc20f9](https://github.com/Gitlawb/node/commit/adc20f9effad7b42fab55002875d209c4ed79518))
* **node:** iCaptcha-aware repo propagation gate with quarantine ([#125](https://github.com/Gitlawb/node/issues/125)) ([8b9ceec](https://github.com/Gitlawb/node/commit/8b9ceec25ef338965d1db72d39a7f2adb5300cc9))
* **node:** owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH ([#31](https://github.com/Gitlawb/node/issues/31)) ([#68](https://github.com/Gitlawb/node/issues/68)) ([0a15e76](https://github.com/Gitlawb/node/commit/0a15e763d2ab46737a3831715facbe51045b33ba))
* **node:** peer partial-mirrors for repos with private subtrees ([#35](https://github.com/Gitlawb/node/issues/35)) ([e365a57](https://github.com/Gitlawb/node/commit/e365a57b51c167abf06e87bfeb0565c80ed1b849))
* **node:** pin the per-push object delta instead of re-enumerating the whole repo ([#90](https://github.com/Gitlawb/node/issues/90)) ([1af4fdf](https://github.com/Gitlawb/node/commit/1af4fdf485c7084cd160551b757b1ad1eed65cc6))
* **node:** replication enforcement (Phase 2) for [#18](https://github.com/Gitlawb/node/issues/18) ([#34](https://github.com/Gitlawb/node/issues/34)) ([8680d0f](https://github.com/Gitlawb/node/commit/8680d0f9d6600bba1a52d15624f8a2802a169511))
* **node:** signature-gated agent self-deregister ([#29](https://github.com/Gitlawb/node/issues/29)) ([#63](https://github.com/Gitlawb/node/issues/63)) ([ff492b4](https://github.com/Gitlawb/node/commit/ff492b452126f5568dac4286a7249f0cadb8b380))
* **node:** subtree content withholding (Phase 3) for [#18](https://github.com/Gitlawb/node/issues/18) ([#28](https://github.com/Gitlawb/node/issues/28)) ([61b3830](https://github.com/Gitlawb/node/commit/61b383019fd895a1a6adfad934ad6c626e0f095e))
* path-scoped repository visibility (Phase 1) for [#18](https://github.com/Gitlawb/node/issues/18) ([#25](https://github.com/Gitlawb/node/issues/25)) ([6abaf1d](https://github.com/Gitlawb/node/commit/6abaf1d7ed8fc55c6547568ae7247131311bde98))
* per-DID rate limiting on creation endpoints (10/hour) ([#13](https://github.com/Gitlawb/node/issues/13)) ([b12c6bc](https://github.com/Gitlawb/node/commit/b12c6bc3283c2647224a62fb520a3cb7acf4a747))
* **sync:** auto-register as replica with origin after successful mirror ([#56](https://github.com/Gitlawb/node/issues/56)) ([c03c9af](https://github.com/Gitlawb/node/commit/c03c9af8fadafe262a2bf3cf25e19edf7160d376))
### Bug Fixes
* **api:** blob endpoint returns 400/404 instead of 500 on bad paths ([#37](https://github.com/Gitlawb/node/issues/37)) ([b61a1bd](https://github.com/Gitlawb/node/commit/b61a1bd46ffe78b39c0967e97b0ad349eba0b046))
* **core:** route seed access through the zeroizing wrapper ([#41](https://github.com/Gitlawb/node/issues/41)) ([#64](https://github.com/Gitlawb/node/issues/64)) ([c9f43b0](https://github.com/Gitlawb/node/commit/c9f43b010576edb3c92a3be3d935cad232250344))
* **core:** zeroize the derived X25519 secret ([#65](https://github.com/Gitlawb/node/issues/65)) ([#91](https://github.com/Gitlawb/node/issues/91)) ([2f6611a](https://github.com/Gitlawb/node/commit/2f6611a30fbb66cef9991cf4c6e507548acc5038))
* **gl:** paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict ([#49](https://github.com/Gitlawb/node/issues/49)) ([#70](https://github.com/Gitlawb/node/issues/70)) ([2153b0b](https://github.com/Gitlawb/node/commit/2153b0b7a67a48c29a33cd70b80cfbb69760805d))
* **infra:** drop fly idle_timeout 600 -&gt; 120 ([#38](https://github.com/Gitlawb/node/issues/38)) ([a2217bf](https://github.com/Gitlawb/node/commit/a2217bff27ff3d6cae02bd8a12f8a0af7ce2b0a1))
* **node:** anchor the real old_sha and issue a per-ref certificate ([#72](https://github.com/Gitlawb/node/issues/72)) ([6809201](https://github.com/Gitlawb/node/commit/6809201daa223d6f833be6e95876a7b4e1f2b0b5))
* **node:** close under-withholding via full ref scope and full-history classification ([#42](https://github.com/Gitlawb/node/issues/42)) ([#84](https://github.com/Gitlawb/node/issues/84)) ([3e1e904](https://github.com/Gitlawb/node/commit/3e1e9045e4e3aa5ea0aee69767ceb01637920d2a))
* **node:** dedupe mirror and canonical repo rows on list surfaces ([#6](https://github.com/Gitlawb/node/issues/6)) ([#73](https://github.com/Gitlawb/node/issues/73)) ([3e8e333](https://github.com/Gitlawb/node/commit/3e8e333aa03d7a2fe455d5d83f23089d58feb8c9))
* **node:** enforce path-scoped visibility on the REST read API ([#52](https://github.com/Gitlawb/node/issues/52)) ([e37ea7f](https://github.com/Gitlawb/node/commit/e37ea7fec6d5a3171b526c84f884670bbbd258fb))
* **node:** fail closed when a recipient DID can't be resolved ([#47](https://github.com/Gitlawb/node/issues/47)) ([#67](https://github.com/Gitlawb/node/issues/67)) ([abc9ad0](https://github.com/Gitlawb/node/commit/abc9ad03acb48e608234650a25049622673fa53a))
* **node:** gate fork_repo on per-caller path-scoped visibility ([#98](https://github.com/Gitlawb/node/issues/98)) ([#109](https://github.com/Gitlawb/node/issues/109)) ([6ae316c](https://github.com/Gitlawb/node/commit/6ae316cc88521747cbacb2a612b9433897d2e490))
* **node:** gate GET /ipfs/{cid} on per-caller path-scoped visibility ([#110](https://github.com/Gitlawb/node/issues/110)) ([#128](https://github.com/Gitlawb/node/issues/128)) ([174f25a](https://github.com/Gitlawb/node/commit/174f25a206380b26796b8782e1bd860b0a409fc9))
* **node:** gate repo-listing and stats surfaces on visibility ([#97](https://github.com/Gitlawb/node/issues/97), [#99](https://github.com/Gitlawb/node/issues/99), [#101](https://github.com/Gitlawb/node/issues/101), [#104](https://github.com/Gitlawb/node/issues/104)) ([#111](https://github.com/Gitlawb/node/issues/111)) ([828dd27](https://github.com/Gitlawb/node/commit/828dd279a286f58bbb3c73627b2a1e23778b25cf))
* **node:** make Tigris repo hydration resilient to corrupt archives & failed writes ([#54](https://github.com/Gitlawb/node/issues/54)) ([7a99d0f](https://github.com/Gitlawb/node/commit/7a99d0f27cfa869e9f8a803f16cff30191745a51))
* **node:** preserve promisor mirror mode on unknown withheld-paths lookup ([#48](https://github.com/Gitlawb/node/issues/48)) ([#69](https://github.com/Gitlawb/node/issues/69)) ([96fcfdb](https://github.com/Gitlawb/node/commit/96fcfdb1ca6d4cd27226670068702c9f177e283a))
* **node:** reap leaked git child processes ([#53](https://github.com/Gitlawb/node/issues/53)) ([#61](https://github.com/Gitlawb/node/issues/61)) ([803d83e](https://github.com/Gitlawb/node/commit/803d83efdbcfa9d98affac1c5aaf8aacc511ae64))
* **node:** reject malformed path globs with non-trailing or empty-segment wildcards ([#74](https://github.com/Gitlawb/node/issues/74)) ([#75](https://github.com/Gitlawb/node/issues/75)) ([3d880cd](https://github.com/Gitlawb/node/commit/3d880cd56fed10e4c5ae787b52ce411384950f5e))
* **node:** skip withheld-walk when no path-scoped rule can withhold ([#60](https://github.com/Gitlawb/node/issues/60)) ([338ff83](https://github.com/Gitlawb/node/commit/338ff83584f2ab960be7e42ecec64191f5aaeb95))
* **release:** give crates explicit versions for release-please ([#129](https://github.com/Gitlawb/node/issues/129)) ([788a868](https://github.com/Gitlawb/node/commit/788a8686c1deafe03573703d72eb927cde81f54d))
* **release:** use simple release-type + generic Cargo.toml updaters ([#130](https://github.com/Gitlawb/node/issues/130)) ([12bfb1b](https://github.com/Gitlawb/node/commit/12bfb1b3c86b38f0b22d488cd6e85dfd96e0c37f))
* **security:** gate webhook creation through the public-host validator ([#81](https://github.com/Gitlawb/node/issues/81)) ([#92](https://github.com/Gitlawb/node/issues/92)) ([f28fa02](https://github.com/Gitlawb/node/commit/f28fa02236bd65c6a4fb690131ab14640cc53a12))
* **security:** reject non-public peer URLs + prune poisoned peers ([#78](https://github.com/Gitlawb/node/issues/78)) ([a8cc33a](https://github.com/Gitlawb/node/commit/a8cc33a185f2649d3fe100ec271ee5739a55eba7))
+7 -29
View File
@@ -1,12 +1,12 @@
# Contributing to Twigpine
# Contributing to gitlawb
Thanks for your interest in contributing. Twigpine is an open protocol — agents and humans welcome.
Thanks for your interest in contributing. gitlawb is an open protocol — agents and humans welcome.
## Quick start for contributors
```sh
# Clone and build
git clone https://github.com/Twigpine/node
git clone https://github.com/Gitlawb/node
cd node
# Build everything
@@ -31,14 +31,12 @@ crates/
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN, ref certs)
├── gitlawb-node/ axum HTTP server, git smart HTTP, P2P, GraphQL
├── gl/ CLI — identity, repos, MCP server, Base L2 names
├── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
├── gitlawb-attest/ provenance attestations for ref-update certificates
└── icaptcha-client/ client for the iCaptcha proof-of-intelligence service
└── git-remote-gitlawb/ git remote helper for gitlawb:// URLs
docs/ Operator guides
scripts/ Build helpers
```
Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https://github.com/Twigpine/contracts).
Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://github.com/Gitlawb/contracts).
## Ways to contribute
@@ -56,31 +54,11 @@ Smart contracts live in a separate repo: [github.com/Twigpine/contracts](https:/
3. **No breaking changes without discussion.** Open an issue first for protocol-level changes.
4. **Conventional commits.** Use `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. Releases are automated by [release-please](https://github.com/googleapis/release-please) — your commit prefixes drive the next version bump.
5. **Format and lint.** Run `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` before submitting. CI will reject anything that fails these.
6. **Security-surface rules.** [`AGENTS.md`](AGENTS.md) carries the authorization invariants PRs most often break; it is written for coding agents and humans alike, so read it before touching endpoints, visibility, or migrations.
## What gets merged, what gets closed
We welcome contributions from humans and agents alike. To keep review sustainable, PRs are
expected to clear a basic quality bar:
- **Link an issue.** Bug fixes and features should reference an issue (`Closes #123`). For
protocol-level changes (identity, signatures, UCAN, ref certs, wire formats), open the
issue *before* writing code.
- **One change per PR.** Unrelated churn slows review and gets sent back.
- **Tests and a green pipeline.** New behavior needs tests; `cargo fmt`, `cargo clippy`,
and the full CI suite must pass.
- **A real description.** Say what changes and why. "Update code" is not a description.
A triage bot labels PRs that are missing these and leaves a short note. Nothing is closed
automatically while you're engaging. A flagged PR still missing a description after 14 days of
inactivity gets a stale warning, and is closed 7 days later if still untouched. A missing issue
link alone never triggers closure; that label stays advisory. Closed PRs can be reopened at any
time once updated.
## Development environment
**Requirements:**
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
- Rust stable (≥ 1.85) — install via [rustup](https://rustup.rs)
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
- Docker (optional, for full-stack local testing)
@@ -108,7 +86,7 @@ cargo test -p gitlawb-node
- **TypeScript SDK** (`@gitlawb/sdk`) — client library for the HTTP API
- **Python SDK** (`gitlawb`) — for ML/agent pipeline integration
- **UCAN authorization** — add trusted issuer anchoring, capability checks, and authorization-aware revocation
- **UCAN chain validation** — complete the auth middleware
- **Filecoin storage tier** — wire up cold storage deals
- **Documentation** — guides, tutorials, API examples
- **Node operators** — run a public node and report issues
Generated
+1078 -1183
View File
File diff suppressed because it is too large. Load diff
+4 -21
View File
@@ -5,23 +5,14 @@ members = [
"crates/gitlawb-node",
"crates/gl",
"crates/git-remote-gitlawb",
"crates/gitlawb-attest",
"crates/icaptcha-client",
]
[workspace.package]
# Per-crate versions live in each crate's [package] so release-please can manage
# them (it cannot rewrite an inherited `version.workspace = true`). They are kept
# in lockstep by release-please's generic updater: each crate's `version` line
# carries an `# x-release-please-version` annotation and is listed under
# `extra-files` in release-please-config.json. The release type is `simple`
# (not `rust`) because the rust strategy unconditionally tries to rewrite this
# virtual workspace root as a package manifest and errors out.
version = "0.3.8"
edition = "2021"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
authors = ["Twigpine contributors"]
repository = "https://github.com/Twigpine/node"
authors = ["gitlawb contributors"]
repository = "https://github.com/gitlawb/node"
[workspace.dependencies]
# async
@@ -29,13 +20,12 @@ tokio = { version = "1", features = ["full"] }
# serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_jcs = "0.2"
# errors
thiserror = "2"
anyhow = "1"
# crypto
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"] }
rand = "0.8.6"
rand = "0.8"
sha2 = "0.10"
# encoding
hex = "0.4.3"
@@ -53,12 +43,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
# http client
reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false }
# URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can
# take a parsed URL without pulling reqwest into gitlawb-core)
url = "2"
# HMAC
hmac = "0.12"
[profile.release]
lto = "thin"
strip = "symbols"
+7 -30
View File
@@ -8,46 +8,32 @@ FROM rust:1.91-bookworm AS builder
WORKDIR /build
# Cache dependencies first for faster rebuilds.
# EVERY workspace member's manifest must be copied here, not just the ones this
# image ships: cargo loads the whole workspace before it resolves anything, so a
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
# layer before a single crate is fetched.
# Cache dependencies first for faster rebuilds
COPY Cargo.toml Cargo.lock ./
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
COPY crates/gl/Cargo.toml crates/gl/
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
# is what let it sit dead from the moment gl gained a path dependency on
# icaptcha-client. A failure here should stop the build, not silently turn every
# image build into a cold one.
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
# Fetch deps (this layer caches until Cargo.{toml,lock} change)
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crates/git-remote-gitlawb/src && \
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
echo 'fn main() {}' > crates/gl/src/main.rs && \
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
echo '' > crates/gitlawb-core/src/lib.rs && \
echo '' > crates/gitlawb-attest/src/lib.rs && \
echo '' > crates/icaptcha-client/src/lib.rs && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb || true
# Now copy real sources and build for real.
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
# cargo can keep the dummy `fn main() {}` binaries from the cache layer above
# and the runtime container exits immediately with code 0.
COPY crates/ crates/
COPY bootstrap-peers.json ./
RUN find crates -name "*.rs" -exec touch {} + && \
rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
target/release/.fingerprint/gl-* \
target/release/.fingerprint/git-remote-gitlawb-* && \
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb && \
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
# ── Runtime stage ───────────────────────────────────────────────────────────
@@ -57,7 +43,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
curl \
tini \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for runtime
@@ -75,23 +60,15 @@ WORKDIR /data
ENV GITLAWB_REPOS_DIR=/data/repos \
GITLAWB_KEY=/data/keys/identity.pem \
GITLAWB_P2P_KEY=/data/keys/p2p.key \
GITLAWB_HOST=0.0.0.0 \
GITLAWB_PORT=7545 \
GITLAWB_P2P_PORT=7546
EXPOSE 7545 7546/udp
EXPOSE 7545 7546
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://127.0.0.1:${GITLAWB_PORT}/health || exit 1
# Run under tini so the node is never PID 1. As PID 1 a process must reap
# reparented orphans itself; the node does not, so git's reparented grandchildren
# (e.g. pack-objects orphaned when a served upload-pack dies on a client
# disconnect) would accumulate as zombies until fork() fails with EAGAIN (#53).
# tini reaps them and forwards SIGTERM/SIGINT to the node, which runs its own
# graceful shutdown. (No `-g`: the node manages its own children, so group-wide
# signalling would only disturb in-flight git helpers during shutdown.)
ENTRYPOINT ["tini", "--", "gitlawb-node"]
ENTRYPOINT ["gitlawb-node"]
+2 -5
View File
@@ -3,10 +3,7 @@
# Used by scripts/build-bins.sh — not used for node deployment.
ARG TARGET=x86_64-unknown-linux-musl
# Must stay at or above the workspace `rust-version` in Cargo.toml. An unmet
# MSRV is a hard cargo error, and nothing in CI builds this file, so a stale
# base image here fails only when someone runs scripts/build-bins.sh.
FROM rust:1.91-bookworm AS builder
FROM rust:1.85-bookworm AS builder
ARG TARGET
WORKDIR /build
@@ -26,7 +23,7 @@ COPY crates/ crates/
# Configure cross-linker for aarch64
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
RUN cargo build --release --locked --target "$TARGET" -p gl -p git-remote-gitlawb
RUN cargo build --release --target "$TARGET" -p gl -p git-remote-gitlawb
FROM debian:bookworm-slim
ARG TARGET
+1 -1
View File
@@ -169,7 +169,7 @@
END OF TERMS AND CONDITIONS
Copyright 2026 Twigpine contributors
Copyright 2026 gitlawb contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Twigpine contributors
Copyright (c) 2026 gitlawb contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+52 -533
View File
@@ -1,379 +1,49 @@
# Twigpine Node
# gitlawb node
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
**Decentralized git for AI agents and developers.** Run your own node, host repos under your own DID, and connect to the gitlawb network.
Twigpine Node is the open-source node software behind the Twigpine network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
This repository contains the open-source node software:
Twigpine is not trying to be only "another git host." The long-term direction is:
```txt
Decentralized GitHub
+ signed agent-native workflows
+ resilient repo replication
+ CDN-style app/code delivery
```
The mission is simple: once code is pushed to the network, it should not disappear because one server went down.
- `gitlawb-node` — Axum HTTP server, git smart-HTTP, libp2p gossip, optional S3-compatible storage
- `gl` — CLI for identity, repos, PRs, MCP server
- `git-remote-gitlawb` — `git remote helper` for `gitlawb://` URLs
- `gitlawb-core` — shared crypto primitives (DID, CID, HTTP signatures, UCAN)
---
## What is in this repository?
## Quickstart
This is a Rust workspace with six crates:
| Crate | Purpose |
|---|---|
| `gitlawb-node` | The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
| `gl` | The Twigpine CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Twigpine nodes. |
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
| `gitlawb-attest` | Pluggable external provenance attestations for ref-update certificates. |
| `icaptcha-client` | Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
---
## Why Twigpine Nodes?
Most git hosting today depends on a small number of centralized platforms. Twigpine Nodes are designed for a different model:
- **Own your identity**: every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
- **Signed writes by default**: write requests use RFC 9421 HTTP Signatures instead of passwords.
- **Git-native transport**: repositories are still real git repositories served over smart HTTP.
- **Agent-native workflows**: the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
- **Peer-aware delivery**: nodes can announce, discover, gossip, and sync with each other.
- **App CDN direction**: the network can evolve from decentralized code storage into code + asset + app delivery.
---
## Current status
Twigpine Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Good today:
- Local or Docker node startup.
- Postgres-backed repo metadata.
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
- DID identities.
- `gl` CLI workflows.
- libp2p peer discovery/gossip foundation.
- Optional Tigris/S3 storage.
- Optional IPFS/Pinata and Arweave/Irys hooks.
- Optional Base node-operator staking/heartbeat hooks.
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.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Read visibility is not a blanket data-classification boundary: task, IPFS-pin, and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
See:
- [`SECURITY.md`](SECURITY.md)
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
---
## Quickstart: run a local node
The fastest path is Docker Compose. It starts a node and Postgres.
The fastest way to run a node is via Docker. You'll get a node + Postgres locally with one command.
```bash
git clone https://github.com/Twigpine/node.git
git clone https://github.com/gitlawb/node.git
cd node
cp .env.example .env
docker compose up -d
```
Your local node will serve:
Your node is now serving:
| Service | Default |
|---|---|
| HTTP API + git smart-HTTP | `http://localhost:7545` |
| libp2p QUIC/UDP | `7546` |
| Postgres | compose-managed |
- HTTP + git smart-HTTP on `:7545`
- libp2p gossip on `:7546`
Verify:
```bash
curl http://localhost:7545/health
curl http://localhost:7545/api/v1/stats
```
Expected health response:
```json
{ "status": "ok" }
```
Stop it:
```bash
docker compose down
```
---
## Install the CLI
## How it works
```bash
# npm (macOS / Linux)
npm install -g @gitlawb/gl
- **Identity** — every actor (human or agent) is an Ed25519 keypair → `did:key:z6Mk...`. No accounts, no passwords.
- **Auth** — every write is signed via HTTP Signatures (RFC 9421).
- **Delegation** — UCAN capability tokens.
- **Storage** — local disk + optional S3-compatible bucket (Tigris, MinIO, AWS S3).
- **Networking** — libp2p Gossipsub for ref-update events, Kademlia DHT for node discovery.
# Homebrew (macOS / Linux)
brew install gitlawb/tap/gl
# curl (macOS / Linux)
curl -fsSL https://twigpine.com/install.sh | sh
# PowerShell (Windows)
irm https://twigpine.com/install.ps1 | iex
```
Or build from source:
```bash
cargo build --release -p gl -p git-remote-gitlawb -p gitlawb-node
```
Put these binaries on your `PATH`:
```txt
target/release/gl
target/release/git-remote-gitlawb
target/release/gitlawb-node
```
Check your setup:
```bash
gl doctor
```
---
## First repo flow
Create an identity:
```bash
gl identity new
gl identity show
```
Register against your local node:
```bash
gl register --node http://localhost:7545
```
Create a repo:
```bash
gl repo create my-repo --description "My first Twigpine repo" --node http://localhost:7545
```
Use the git remote helper:
```bash
export GITLAWB_NODE=http://localhost:7545
git clone gitlawb://did:key:z6Mk.../my-repo
```
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
### Full lifecycle against an iCaptcha-enforcing node
Public nodes (e.g. `node.twigpine.com`) require two things on writes:
1. **RFC 9421 HTTP Signatures**: every write is signed by your identity key. `gl`
and the `git-remote-gitlawb` helper do this automatically. An old/unsigned CLI
fails with `401 not_an_agent`; `gl` will tell you to upgrade and register.
2. **An iCaptcha proof** on the spam-gated writes (**repo create, fork, register**).
`gl` solves this for you: on the node's `403 icaptcha_proof_required` it reads the
`x-icaptcha-url` / `x-icaptcha-level` hints, requests a challenge, solves it
locally (arithmetic / algebra / sequence), and **retries the same signed request**
with the `x-icaptcha-proof` header. No manual steps, no env vars.
```bash
gl identity new # create did:key identity
gl register --node https://node.twigpine.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.twigpine.com # signed + auto-solves iCaptcha
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
gl doctor # preflight: identity, node, version, iCaptcha
```
Notes:
- **`requesterId` is always your DID.** The proof's `sub` claim must equal the
authenticated signer; `gl`/helper set this automatically and the node enforces
`sub == authenticated DID` (so a proof minted for another identity is rejected).
- **Proofs are short-lived (~5 min TTL) and single-use.** If one expires between
solving and use, the client transparently solves a fresh one and retries.
- **What needs what:** create / fork / register are signed **and** iCaptcha-gated;
`git push` is **signed-only** (owner signature is the gate, no per-push challenge);
reads (clone / fetch / `repo info`) need no proof. A non-existent repo returns a
clear `404`, never a placeholder.
- **API-key iCaptcha deployments:** set `GITLAWB_ICAPTCHA_URL` to your iCaptcha
origin and `GITLAWB_ICAPTCHA_API_KEY` to its key. The client only talks to an
`https` origin whose host is allowlisted (that URL or the public default), and
sends the bearer token **only** to your configured origin, never to a URL a
node advertises, so a hostile node can't capture the key or redirect the solve.
### Fetching an object by CID
```bash
gl ipfs list # CIDs this node has pinned
gl ipfs get bafkrei... > object.bin # object bytes on stdout
```
Objects that were pinned before the node started recording which repo they came
from are found by scanning its repo inventory, and that scan stops at the
per-request ceilings in the [Configuration](#configuration) table. A stopped scan
answers 503 with a resume token instead of a false "not found", and `gl ipfs get`
follows the token automatically: up to 8 resumes after the first request, so at
most 9 calls to the node, waiting between attempts for as long as the node's
`Retry-After` asks and never longer than 5 seconds.
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds
the search, not the download: each attempt gets the time left on it to produce
response headers, and once an object is found its bytes stream outside that
deadline. They are not unbounded, though. The client's own 30 second HTTP timeout
is a total request timeout, running from the start of a request until its body
has finished, so a transfer still going 30 seconds after its request began is cut
off. Waits between attempts never run past the deadline either, so a single run
spends at most around 90 seconds on the network: the deadline plus the 30 second
timeout covering the last attempt. Writing the object out sits outside both
bounds, so piping into a reader that stops reading can hold the command open
longer than that.
Two node-side brakes end a ladder early and are reported rather than retried
around. A 429 is terminal, because the node's rate-limit window is an hour and
that wait cannot be honored inside one invocation; a transient overload (a 503
carrying no incomplete-scan code) is retried on the token already held, under the
same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well
short of the 9 calls, so automatic resumption is not a guarantee of reaching the
object.
When a bound stops the ladder with a usable token in hand, the command prints the
token and the invocation that continues from it before exiting nonzero:
```txt
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
```
Run that to carry on from where the scan stopped. Re-running without `--scan`
restarts at the first row, reproduces the same truncation and spends the node's
per-IP budget again, so the token is the only thing that makes progress. Tokens
are valid for an hour.
---
## Architecture
```txt
┌──────────────────────────┐
│ gl CLI / git / AI agents │
└────────────┬─────────────┘
│ signed HTTP writes / git smart-HTTP
↓
┌──────────────────────────┐
│ gitlawb-node │
│ Axum API + git routes │
└────────────┬─────────────┘
│
┌────────┴────────┐
↓ ↓
Postgres Bare git repos
metadata local disk / optional S3
│ │
└────────┬────────┘
↓
libp2p peers
gossip + discovery + sync
↓
optional IPFS / Arweave / Base PoS
```
### Core concepts
| Concept | Meaning |
|---|---|
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| UCAN | Delegation token for future capability-based workflows. |
| 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. |
---
## API surface
The node exposes both git smart-HTTP routes and JSON APIs.
Common public read routes:
```txt
GET /health
GET /
GET /api/v1/stats
GET /api/v1/contracts
GET /api/v1/repos
GET /api/v1/repos/{owner}/{repo}
GET /api/v1/repos/{owner}/{repo}/tree
GET /api/v1/repos/{owner}/{repo}/blob/{path}
GET /api/v1/repos/{owner}/{repo}/issues
GET /api/v1/repos/{owner}/{repo}/pulls
GET /api/v1/peers
GET /{owner}/{repo}/info/refs
POST /{owner}/{repo}/git-upload-pack
```
Signed write routes include:
```txt
POST /api/v1/repos
POST /api/register
POST /api/v1/repos/{owner}/{repo}/fork
POST /api/v1/repos/{owner}/{repo}/issues
POST /api/v1/repos/{owner}/{repo}/pulls
POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge
POST /api/v1/repos/{owner}/{repo}/hooks
POST /api/v1/bounties/{id}/...
POST /{owner}/{repo}/git-receive-pack
```
These peer write routes support staged rollout:
```txt
POST /api/v1/peers/announce
POST /api/v1/sync/notify
```
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Staged rollout relaxes who may announce, not who owns a peer row. An unsigned announce may register a previously unseen peer, and it may refresh an existing row whose `http_url` is unchanged, but changing an existing peer's `http_url` requires an RFC 9421 signature from that peer's own DID and is refused with 403 otherwise. An unsigned announce can only register a `did:key`, since that is the only method whose verifying key can be resolved and therefore the only kind of row anyone could ever correct through the signed path; any other DID is refused with 400. Once all live peers upgrade, operators can set:
```bash
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
```
`POST /api/v1/sync/trigger` is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
The flag is not HTTP-only. It also gates inbound gossip ref-update events on the libp2p mesh, which carry an Ed25519 payload signature rather than RFC 9421 headers because there are no HTTP headers to sign. Set to `true`, an unsigned gossip event is dropped. Set to `false`, an unsigned event is accepted with a warning during the rolling-upgrade window, and the write budget it consumes is charged to the forwarding peer rather than to the DID it claims, since an unsigned event's claimed DID is asserted and not proven. A present-but-invalid signature is refused in both modes; that is forgery, not an un-upgraded peer.
Because the flag now spans both transports, the rollout order matters in one direction: **upgrade every gossip-publishing peer to a build that signs events before you set this to `true`.** Enabling it while an old publisher is still live drops that publisher's ref-updates on arrival, and the publisher sees no error, because gossip has no response to carry one. Upgrading the HTTP peers alone is not sufficient.
There is a second precondition on gossip ingest that the flag does not control. A publisher's `node_did` must already exist in the receiving node's peers table, or the event is dropped as an unknown peer DID. This holds in both modes, signed and unsigned alike: a valid signature proves key possession, not membership. Rows reach that table over HTTP, either from `GITLAWB_BOOTSTRAP_PEERS` when this node contacts a bootstrap peer and records the DID it reports, or from a prior `POST /api/v1/peers/announce`. So a peer that only ever joined the libp2p mesh, with no HTTP announce and no bootstrap contact in either direction, will have its ref-updates dropped even with a good signature and the flag off. If gossip is silently not landing from a peer you can see on the mesh, check that its DID is in `GET /api/v1/peers` on the receiving side first.
The node is **stateless beyond Postgres + the repos directory.** You can scale horizontally by pointing multiple nodes at a shared S3 bucket and database.
---
@@ -381,227 +51,76 @@ There is a second precondition on gossip ingest that the flag does not control.
All configuration is via environment variables. See [`.env.example`](.env.example) for the full reference.
Minimum required for a persistent node:
The minimum required:
```env
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
```
- `DATABASE_URL` — Postgres connection string
Important node settings:
Everything else has sensible defaults. Optional features:
| Variable | Purpose |
|---|---|
| `GITLAWB_HOST` / `GITLAWB_PORT` | HTTP bind address and port. |
| `GITLAWB_REPOS_DIR` | Local bare repo storage directory. |
| `GITLAWB_PUBLIC_URL` | Public HTTP URL announced to peers. |
| `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. |
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `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_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
| `GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS` | Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
| `GITLAWB_MAX_CONCURRENT_GIT_OPS` | Max concurrent served git READ ops (upload-pack and its `info/refs` advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER`. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
| `GITLAWB_MAX_CONCURRENT_GIT_PUSHES` | Max concurrent `git-receive-pack` POST operations, in a pool separate from the read pool. The anon receive-pack `info/refs` advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (`/8`, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
| `GITLAWB_MAX_CONCURRENT_READS_PER_CALLER` | Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as `GITLAWB_TRUSTED_PROXY`: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
| `GITLAWB_MAX_CONCURRENT_PIN_TASKS` | Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
| `GITLAWB_REPO_LEASE_MAX_WAITERS` | Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
| `GITLAWB_MAX_CONCURRENT_IPFS_WALKS` | Max concurrent `GET /ipfs/{cid}` visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
| `GITLAWB_IPFS_WALK_PER_SOURCE` | Max concurrent `/ipfs` walks a single source IP may hold. Default 4. |
| `GITLAWB_IPFS_MAX_LEGACY_PROBES` | Max legacy (NULL-provenance) repos probed per `/ipfs/{cid}` request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
| `GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS` | Max repo rows one `/ipfs/{cid}` request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque `continuation` token; echoing it as `?scan=` resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, `ceil(repos / ceiling) + 1` when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's `/ipfs` work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
| `GITLAWB_IPFS_MAX_REPOS_WALKED` | Max expensive path-scope visibility walks a `/ipfs/{cid}` request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (`MAX_PIN_SOURCES + 1`), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
| `GITLAWB_IPFS_MAX_REPO_VISITS` | Ceiling on repos one `/ipfs/{cid}` request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
| `GITLAWB_IPFS_REQUEST_BUDGET_SECS` | Absolute wall-clock budget for one admitted `/ipfs/{cid}` request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read `cat-file` subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` and the remaining budget), reaped via process-group teardown, so a hung `cat-file` cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
| `GITLAWB_IPFS_RESOLVE_BUDGET_SECS` | Shorter budget for the pre-walk CID resolve inside an admitted `/ipfs/{cid}` request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above `GITLAWB_IPFS_REQUEST_BUDGET_SECS` degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
| `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
- `GITLAWB_TIGRIS_BUCKET` (+ AWS credentials) — S3-compatible shared storage
- `GITLAWB_PINATA_JWT` — IPFS warm-storage pinning
- `GITLAWB_IRYS_URL` — Arweave permanent anchoring
- `GITLAWB_BOOTSTRAP_PEERS` — comma-separated peer URLs to announce to on startup
- `GITLAWB_P2P_BOOTSTRAP` — comma-separated libp2p multiaddrs
Production note: change the default Postgres password before exposing a node publicly.
### On-chain Proof-of-Stake (advanced, optional)
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The `/ipfs/{cid}` resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so `GET /api/v1/ipfs/pins` can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in `legacy_provider_cid`), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
The node supports an optional on-chain PoS layer where operators register their DID, stake $GITLAWB on Base L2, and receive a share of protocol fees. Smart contracts are in a separate repository at [github.com/gitlawb/contracts](https://github.com/gitlawb/contracts).
### Upgrading: the PeerId rotates once
This node's libp2p identity is now a keypair generated on first start and kept
at `GITLAWB_P2P_KEY`, rather than one derived from the node DID. Every node
therefore gets a new PeerId once, on the first start after upgrading, and keeps
it from then on as long as that key file survives (put it on a persistent volume
in a container).
Two things to check before upgrading:
- Any `GITLAWB_P2P_BOOTSTRAP` multiaddr that pins a peer's old PeerId with a
`/p2p/<PeerId>` suffix stops matching once that peer upgrades. Update the
suffix, or drop it and let identify supply the current one. Addresses without
the suffix keep working untouched.
- `GITLAWB_P2P_KEY` must name a key file inside a directory, such as
`/data/keys/p2p.key` or `./keys/p2p.key`. A bare filename is refused at
startup, since the node will not keep its identity key in the working
directory.
Peers found over `GITLAWB_BOOTSTRAP_PEERS` and the embedded seed list are
unaffected, since those are HTTP URLs and carry no PeerId.
PoS is **disabled by default** and currently paused pending external audit. To enable, set `GITLAWB_CONTRACT_NODE_STAKING` and `GITLAWB_OPERATOR_PRIVATE_KEY`. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md) for details.
---
## Optional node staking
## Build from source
Twigpine Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
PoS is disabled unless these are configured:
```env
GITLAWB_CONTRACT_NODE_STAKING=0x...
GITLAWB_OPERATOR_PRIVATE_KEY=0x...
GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org
```
Recommended for operators:
```env
GITLAWB_OPERATOR_STRICT_MODE=true
GITLAWB_HEARTBEAT_INTERVAL_HOURS=20
```
Read:
- [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md)
- [`docs/ECONOMICS.md`](docs/ECONOMICS.md)
Use a dedicated low-balance operator wallet. Do not use a treasury wallet as the heartbeat key.
---
## Building from source
Requires Rust 1.91+.
Requires Rust 1.85+ and a Postgres instance.
```bash
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb
```
Run tests:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
Run the node from source:
```bash
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb \
cargo run -p gitlawb-node --release
```
Binaries are placed in `target/release/`. The `gl` and `git-remote-gitlawb` binaries should be on your `$PATH` for client use.
---
## macOS menu bar app
A native Swift/AppKit menu bar app is included for managing a local Docker Compose stack without living in the terminal.
Requirements:
- macOS 26+
- Xcode Command Line Tools
- Docker Desktop, OrbStack, or Colima
Build:
## CLI usage
```bash
./scripts/build-macos-app.sh
gl identity new
gl register --node http://localhost:7545
gl repo create my-repo --description "..."
git clone gitlawb://did:key:z6Mk.../my-repo
```
Output:
```txt
dist/Twigpine Node.app
dist/GitlawbNode-macOS.dmg
```
Features:
- Start/stop local node stack.
- Status indicator.
- Settings GUI.
- Auto-start on login.
- Docker runtime detection.
Unsigned local build:
```bash
xattr -cr "dist/Twigpine Node.app"
```
Full CLI reference: `gl --help`.
---
## Roadmap
## Architecture
The current maintainer focus is live-network stability first.
Short-term priorities:
1. Keep CI green: fmt, clippy, tests, release build.
2. Add Docker and installer smoke tests.
3. Improve operator docs and `gl doctor` checks.
4. Harden peer writes and publish the signed-peer rollout plan.
5. Wire trusted UCAN delegation into repository write authorization.
6. Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
Product direction:
1. Reliable repo replication.
2. Health-aware peer syncing.
3. CDN-style clone/fetch routing to healthy replicas.
4. App asset/build delivery from nodes.
5. Operator dashboard and desktop UX.
Read the maintainer roadmap:
```txt
docs/MAINTAINER-ROADMAP.md
```
crates/
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN)
├── gitlawb-node/ node daemon (Axum + git smart-HTTP)
├── gl/ CLI
└── git-remote-gitlawb/ git remote helper
```
---
## Contributing
Start here:
- [`CONTRIBUTING.md`](CONTRIBUTING.md)
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
- [`SECURITY.md`](SECURITY.md)
Good first contribution areas:
- docs and install polish
- Docker smoke tests
- CLI error messages
- `gl doctor` checks
- operator dashboard UX
- test coverage for peer sync and signed writes
Security issues should follow [`SECURITY.md`](SECURITY.md).
---
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security issues: see [`SECURITY.md`](SECURITY.md).
## License
Licensed under either of:
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual licensed as above, without any additional terms or conditions.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
+31 -52
View File
@@ -2,9 +2,9 @@
## Reporting a Vulnerability
If you discover a security vulnerability in Twigpine, please **do not open a public issue**.
If you discover a security vulnerability in gitlawb, please **do not open a public issue**.
Report it privately by emailing **security@twigpine.com** with:
Report it privately by emailing **security@gitlawb.com** with:
- A description of the vulnerability
- Steps to reproduce
- Potential impact assessment
@@ -16,7 +16,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
## Current Security Architecture
### Current live security controls
### What is production-ready (v0.1)
**Ed25519 identity and HTTP Signatures**
- Every write operation is signed with RFC 9421 HTTP Signatures
@@ -28,11 +28,10 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
- Every git object is content-addressed via CIDv1 (SHA-256)
- Tamper-evident by construction — a modified object changes its CID
**UCAN token validation**
- Bootstrap UCAN tokens are issued at registration.
- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated.
- Tokens use a signed JSON wire format with expiry.
- Capability grants are not yet consulted by repository write authorization; see the limitations below.
**UCAN capability tokens**
- Bootstrap UCAN tokens issued at registration
- Capability-scoped: `git:push`, `git:fetch`, `issue:create`, `pr:open`
- JWT-format tokens with expiry
**Smart contracts (Base Sepolia testnet)**
- `GitlawbDIDRegistry` — on-chain DID → document registry
@@ -43,54 +42,34 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
## Dependency Vulnerability Status
| Area | Status |
|------|--------|
| Dependabot alerts | Current open Rust alerts were addressed by updating vulnerable dependencies, removing libp2p mDNS, and moving P2P transport from TCP/Yamux to QUIC/UDP. |
| Crate | Advisory | Severity | Status |
|-------|----------|----------|--------|
| `rsa 0.9.x` | RUSTSEC-2023-0071 Marvin Attack | Medium | Unexploitable — bundled via `sqlx-mysql` internals; gitlawb uses PostgreSQL only, MySQL auth code is never executed. No upstream fix available. |
| `lru 0.12.x` | RUSTSEC-2026-0002 unsound IterMut | Warning | In `libp2p-swarm`; the specific unsafe code path is internal to libp2p and not reachable through gitlawb's usage. No upstream fix yet. |
---
## Known Limitations
## Known Limitations (Planned for v0.2)
These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades.
These are **documented, accepted limitations** for the current testnet release. They will be addressed before mainnet.
### Repository write authorization defaults
- `git-receive-pack` verifies HTTP Signatures and, with the default configuration, also requires the pusher DID to be the repository owner: `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `true`.
- **Impact:** With the default setting, a valid signature authenticates the pusher *and* the pusher must be the repository 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, including private ones.
- **Mitigation:** `GITLAWB_ENFORCE_OWNER_PUSH=false` is the explicit rolling-upgrade compatibility override. Disable it only for the duration of an upgrade whose pushers are not yet the repo owner, then turn it back on. 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.
### UCAN delegation and revocation
- The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access.
- Agent lifecycle revocation is not checked by HTTP Signature authorization. Removing or revoking an agent record does not itself block a compromised DID from authenticating.
- **Impact:** Do not use UCANs for collaborator permissions or rely on agent-record revocation as a key-compromise response.
- **Mitigation:** Keep sensitive deployments behind operational network controls and enable owner-only push enforcement where it fits the deployment until trusted delegation and authorization-aware revocation are implemented.
### git-receive-pack authentication
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
- **Impact:** Direct HTTP pushes without RFC 9421 headers are rejected; users need `gitlawb://` remotes or equivalent signed clients.
- **Mitigation:** Use `gitlawb://` remote URLs and keep `git-remote-gitlawb` on the user's `PATH`.
### UCAN chain validation
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
- **Mitigation:** Nodes can set `GITLAWB_PUBLIC_READ=false` to restrict access. The v0.1 trust score system provides soft rate limiting.
- **Fix target:** v0.2
### Private repository reads
- Repository and path-scoped visibility checks are enforced for repository API and Git content reads. A denied whole-repository or root read returns the same 404 shape as a missing repository, so the denial does not reveal private-repository existence.
- Sparse-clone support exposes withheld path globs to callers who may read the repository root. Do not put sensitive information in withheld path names.
- `GET /api/v1/tasks`, `/api/v1/ipfs/pins`, and `/api/v1/arweave/anchors` are not repository-gated. Task records include a UCAN token; pin and anchor listings expose object and ref metadata.
- Changing a repository's visibility controls future serving, but cannot retract ref metadata or configured external pins and anchors already announced while the repository was public. Do not push secrets to an announceable repository.
- **Impact:** Visibility policies protect the repository and Git content routes they gate, not every metadata endpoint or previously published content.
- **Remaining boundary:** This read control does not address the independent write-authorization and UCAN-delegation limitations described above.
### UCAN revocation
- Issued UCAN tokens cannot be revoked before expiry.
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Nodes will reject the old DID on sight if manually blocklisted.
- **Fix target:** v0.2
### Pull-request review enforcement
- Pull-request review comments are not yet threaded or line-anchored, and merges do not enforce required approvals.
- **Impact:** Teams must use their own review policy or external controls for merge approval requirements.
### GraphQL mutation coverage
- Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations.
- **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence.
### Peer route hardening rollout
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
- **Impact:** Unsigned peer writes are still accepted by default so existing live nodes can keep communicating during rolling upgrades.
- **Mitigation:** After all active peers run signed-node builds, operators can set `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true`.
- **Fix target:** staged rollout
### git-receive-pack authentication
- The `git-receive-pack` endpoint enforces HTTP Signature auth via the `git-remote-gitlawb` remote helper. Direct HTTP push without the helper is currently unauthenticated on v0.1 nodes.
- **Impact:** A node operator accepting direct HTTP git pushes (not via `gitlawb://`) cannot verify the pusher's identity.
- **Mitigation:** Use `gitlawb://` remote URLs. Do not expose `POST /*/git-receive-pack` to untrusted networks.
- **Fix target:** v0.2
---
@@ -111,20 +90,20 @@ These are documented limitations of the current live release. They should be pri
| Key storage | PKCS#8 PEM, 0600 permissions |
| Content hashing | SHA-256 via CIDv1 |
| HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) |
| UCAN tokens | Signed JSON object (Ed25519 signature) |
| UCAN tokens | JWT (Ed25519 signatures) |
| On-chain | ECDSA secp256k1 (Base L2 / Ethereum) |
---
## Threat Model
Twigpine is designed to be secure against:
- **Unauthenticated writes** — HTTP Signature auth on protected write endpoints; see Known Limitations for owner and capability authorization gaps
gitlawb is designed to be secure against:
- **Unauthorized writes** — HTTP Signature auth on all write endpoints
- **Tampered git objects** — CIDv1 content addressing detects modification
- **Identity spoofing** — DIDs derived from public keys, unforgeable without the private key
- **Centralized takedown** — no single point of control; data on IPFS + Arweave
Twigpine is **not yet** designed to defend against:
gitlawb is **not yet** designed to defend against:
- A compromised node operator (node operators are trusted for their own node)
- Sybil attacks on the DHT (trust score system mitigates, not eliminates)
- Timing attacks on signature verification (not constant-time compared in v0.1)
-47
View File
@@ -1,47 +0,0 @@
{
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-06-30",
"peers": [
{
"name": "gitlawb",
"operator": "Twigpine",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-04-29"
},
{
"name": "gitlawb-node2",
"operator": "Twigpine",
"did": null,
"http_url": "https://node2.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "gitlawb-node3",
"operator": "Twigpine",
"did": null,
"http_url": "https://node3.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "gitlawb-manila",
"operator": "Twigpine",
"did": null,
"http_url": "https://manila.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-06-30"
},
{
"name": "rapybus-gitlawb",
"operator": "RapyBus S.A.C.",
"did": "did:key:z6MktERLyhFpjUyN7dnkf7CYY5RXKJ6BTZiKohoi37YVBS7R",
"http_url": "https://rapybus-gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-05-16"
}
]
}
-112
View File
@@ -1,112 +0,0 @@
# Dependency allowlist for the `gitlawb-core` crate.
#
# gitlawb-core is the shared-primitives crate every consumer embeds (the `gl`
# CLI, git-remote-gitlawb, the node daemon). It must stay lean so those
# consumers do not inherit daemon-weight dependencies. This file is the source
# of truth for the crates gitlawb-core's NORMAL (non-dev, non-build) transitive
# dependency closure is allowed to contain. CI (`scripts/check-gitlawb-core-deps.sh`,
# wired into the `core-deps-purity` job in .github/workflows/pr-checks.yml)
# fails if gitlawb-core's tree contains any crate not listed here.
#
# The allowlist is exhaustive-by-construction: a NEW dependency reds CI whether
# or not anyone thought to ban it, which a denylist cannot do.
#
# Measured in-workspace, so feature unification across the workspace can, in
# rare cases, surface a crate here that gitlawb-core would not pull standalone.
# An unexpected new entry is a prompt to check what pulled it in, not noise to
# rubber-stamp.
#
# Regenerate (from repo root, after an INTENTIONAL dependency change):
# cargo tree -p gitlawb-core --edges normal --prefix none \
# | sed -E 's/ v[0-9].*$//' \
# | grep -v '^gitlawb-core$' \
# | sort -u
# then paste the result below this header block. (The checker snapshots and
# restores Cargo.lock; a manual regen may refresh it — `git checkout Cargo.lock`
# afterward if you did not intend that.)
#
# Lines starting with `#` and blank lines are ignored by the checker.
aead
anyhow
base-x
base256emoji
base64
base64ct
block-buffer
cfg-if
chacha20
chacha20poly1305
chrono
cid
cipher
const-oid
const-str
core2
cpufeatures
crypto-common
crypto_box
crypto_secretbox
curve25519-dalek
curve25519-dalek-derive
data-encoding
data-encoding-macro
data-encoding-macro-internal
der
digest
ed25519
ed25519-dalek
equivalent
generic-array
getrandom
hashbrown
hex
iana-time-zone
indexmap
inout
itoa
libc
match-lookup
memchr
multibase
multihash
multihash-codetable
multihash-derive
multihash-derive-impl
num-traits
opaque-debug
pem-rfc7468
pkcs8
poly1305
ppv-lite86
proc-macro-crate
proc-macro2
quote
rand
rand_chacha
rand_core
salsa20
serde
serde_core
serde_derive
serde_json
sha2
signature
spki
subtle
syn
synstructure
thiserror
thiserror-impl
toml_datetime
toml_edit
toml_parser
typenum
unicode-ident
universal-hash
unsigned-varint
uuid
winnow
zerocopy
zeroize
zeroize_derive
zmij
+2 -23
View File
@@ -1,9 +1,8 @@
[package]
name = "git-remote-gitlawb"
description = "Git remote helper — enables 'git clone gitlawb://did:gitlawb:...'"
version = "0.7.1" # x-release-please-version
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
@@ -11,28 +10,8 @@ name = "git-remote-gitlawb"
path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core", features = ["redirect"] }
gitlawb-core = { path = "../gitlawb-core" }
anyhow = { workspace = true }
reqwest = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
mockito = "1"
tempfile = "3"
[target.'cfg(unix)'.dev-dependencies]
libc = "0.2"
[target.'cfg(windows)'.dev-dependencies]
# Job Object bindings for the real_git_fetch harness: the Windows analog of the
# unix process-group teardown, so the whole `git fetch` tree can be killed
# together on both the timeout and clean-exit paths (INV-22). Win32_Security is
# required by CreateJobObjectW, and Win32_System_Threading by
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION (it embeds IO_COUNTERS).
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for Twigpine ref-update certs"
version = "0.7.1" # x-release-please-version
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
serde_jcs = { workspace = true }
thiserror = { workspace = true }
ed25519-dalek = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
multibase = { workspace = true }
[dev-dependencies]
gitlawb-core = { path = "../gitlawb-core" }
rand = { workspace = true }
-762
View File
@@ -1,762 +0,0 @@
//! One typed, signed, cert-bound provenance blob.
//!
//! Fields: `type` (discriminator), `payload` (opaque, type-specific JSON),
//! `cert_hash` (binds to one ref-update cert), `signer` (`did:key`), and
//! `sig` (base64url-no-pad ed25519).
//!
//! The signature covers the domain-separated input
//! `b"gitlawb-attest-sig/v1\n"` followed by JCS-encoded `{type, payload,
//! cert_hash}` per RFC 8785. The domain tag is in the byte stream rather
//! than the canonical JSON so the wire shape stays additive: an `Attestation`
//! deserialized today and one constructed by a future `v2` signer differ only
//! by which tag the verifier prepends.
//!
//! Type discriminators are slash-separated namespace + version strings
//! (`covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`); verifiers register
//! by exact match.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::error::{Error, Result};
/// Domain-separation tag prepended to the JCS signing input. Bumped to
/// `v2` if the signing-input shape changes.
const SIGNING_DOMAIN: &[u8] = b"gitlawb-attest-sig/v1\n";
/// Multicodec varint prefix for Ed25519 public keys (0xed 0x01).
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
/// A signed provenance blob attached to a ref-update cert.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attestation {
/// Type discriminator, e.g. `covenant/exec/v1`.
#[serde(rename = "type")]
pub type_: String,
/// Type-specific payload. The verifier reparses into its concrete shape.
pub payload: serde_json::Value,
/// Lowercase SHA-256 hex of the cert body. Binds this attestation to one
/// cert. The lowercase form is canonical because the JCS signing input
/// includes this field verbatim — accepting mixed case on the wire would
/// require both signer and verifier to normalize before computing the
/// JCS bytes, an easy spec rule to miss across language ports.
pub cert_hash: String,
/// `did:key` of the signer; the verifying key is recoverable from it.
pub signer: String,
/// base64url-no-pad ed25519 signature over the JCS signing input.
pub sig: String,
}
/// Type-specific payload. Implement on a struct that is `Serialize +
/// DeserializeOwned` to participate.
pub trait AttestationPayload: Serialize + DeserializeOwned + Send + Sync {
/// The discriminator string written on the wire.
fn payload_type() -> &'static str;
}
#[derive(Serialize)]
struct SigningInput<'a> {
#[serde(rename = "type")]
type_: &'a str,
payload: &'a serde_json::Value,
cert_hash: &'a str,
}
impl Attestation {
/// Sign a fresh attestation. `cert_hash_bytes` comes from
/// [`crate::cert::cert_hash`].
pub fn sign<P: AttestationPayload>(
signing_key: &SigningKey,
payload: P,
cert_hash_bytes: [u8; 32],
) -> Result<Self> {
let type_ = P::payload_type().to_string();
validate_type(&type_)?;
let payload_value = serde_json::to_value(payload)?;
let cert_hash_hex = hex::encode(cert_hash_bytes);
let bytes = canonical_signing_bytes(&type_, &payload_value, &cert_hash_hex)?;
let sig: Signature = signing_key.sign(&bytes);
Ok(Self {
type_,
payload: payload_value,
cert_hash: cert_hash_hex,
signer: did_key_from_verifying_key(&signing_key.verifying_key()),
sig: B64U.encode(sig.to_bytes()),
})
}
/// Verify the signature and check that `cert_hash` matches
/// `expected_cert_hash`. Returns the recovered verifying key so the caller
/// can check it against an allowlist.
pub fn verify_signature(&self, expected_cert_hash: [u8; 32]) -> Result<VerifyingKey> {
let expected_hex = hex::encode(expected_cert_hash);
if self.cert_hash != expected_hex {
return Err(Error::CertHashMismatch);
}
validate_type(&self.type_)?;
let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?;
let vk = verifying_key_from_did_key(&self.signer)?;
self.verify_sig_with_key(&vk, &bytes)?;
Ok(vk)
}
/// The signature check itself, against a key the caller supplies.
///
/// Split out so the STRICTNESS of the check stays provable on its own.
/// `verifying_key_from_did_key` now refuses a small-order key, so a weak
/// signer can no longer reach this line through `verify_signature`. Without
/// this seam there would be nothing left that fails when `verify_strict` is
/// downgraded to the non-strict `verify`, and that regression would land
/// silently. Check order in `verify_signature` is unchanged.
fn verify_sig_with_key(&self, vk: &VerifyingKey, bytes: &[u8]) -> Result<()> {
let sig_bytes: [u8; 64] = B64U
.decode(&self.sig)
.map_err(|e| Error::Signature(format!("base64url: {e}")))?
.try_into()
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
vk.verify_strict(bytes, &sig)
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
Ok(())
}
/// Reparse `payload` as `P`. Errors if the type discriminator does not
/// match `P::payload_type()`.
pub fn payload_as<P: AttestationPayload>(&self) -> Result<P> {
if self.type_ != P::payload_type() {
return Err(Error::Type(format!(
"expected '{}', got '{}'",
P::payload_type(),
self.type_
)));
}
Ok(P::deserialize(&self.payload)?)
}
}
/// Discriminator grammar: nonempty, ASCII-graphic only, slash-separated
/// segments of `[a-zA-Z0-9._+-]`, no empty segment, capped at 128 bytes.
/// Matches `covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`.
fn validate_type(t: &str) -> Result<()> {
if t.is_empty() {
return Err(Error::Type("empty discriminator".to_string()));
}
if t.len() > 128 {
return Err(Error::Type(format!(
"discriminator longer than 128 bytes ({})",
t.len()
)));
}
if !t.is_ascii() {
return Err(Error::Type(format!("discriminator must be ASCII: '{t}'")));
}
for segment in t.split('/') {
if segment.is_empty() {
return Err(Error::Type(format!(
"discriminator has an empty segment: '{t}'"
)));
}
for c in segment.chars() {
if !(c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '+' || c == '-') {
return Err(Error::Type(format!(
"discriminator segment '{segment}' has disallowed character '{c}'"
)));
}
}
}
Ok(())
}
fn canonical_signing_bytes(
type_: &str,
payload: &serde_json::Value,
cert_hash_hex: &str,
) -> Result<Vec<u8>> {
let input = SigningInput {
type_,
payload,
cert_hash: cert_hash_hex,
};
let jcs = serde_jcs::to_vec(&input).map_err(|e| Error::Jcs(e.to_string()))?;
let mut out = Vec::with_capacity(SIGNING_DOMAIN.len() + jcs.len());
out.extend_from_slice(SIGNING_DOMAIN);
out.extend_from_slice(&jcs);
Ok(out)
}
pub(crate) fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&key.to_bytes());
let encoded = multibase::encode(multibase::Base::Base58Btc, &buf);
format!("did:key:{encoded}")
}
/// The DID:key spec mandates base58btc (multibase `z`-prefix). Other multibase
/// encodings of the same key bytes are rejected so allowlists, dedup, and
/// signer-pinning policies that compare `signer` strings remain consistent
/// across peers.
fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
let method_id = did
.strip_prefix("did:key:")
.ok_or_else(|| Error::Did(format!("not a did:key: {did}")))?;
if !method_id.starts_with('z') {
return Err(Error::Did(format!(
"did:key must use base58btc (z-prefix): {did}"
)));
}
// Refuse an oversized id before decoding. base58 decoding is quadratic in
// its input and `signer` comes off an attacker-supplied attestation, so a
// short request could otherwise buy a large decode. An ed25519 did:key
// method-id is a fixed 48 characters, so this bound is slack rather than a
// behavior change, and it only helps ahead of the decode. Mirrors the same
// cap in gitlawb-core's Did::to_verifying_key, which this crate cannot call
// (gitlawb-core is a dev-dependency here).
const MAX_METHOD_ID_LEN: usize = 64;
if method_id.len() > MAX_METHOD_ID_LEN {
return Err(Error::Did(
"did:key method-specific id too long".to_string(),
));
}
let (base, bytes) =
multibase::decode(method_id).map_err(|e| Error::Did(format!("multibase: {e}")))?;
if base != multibase::Base::Base58Btc {
return Err(Error::Did(format!(
"did:key must use base58btc (z-prefix): {did}"
)));
}
if bytes.len() != ED25519_MULTICODEC.len() + 32
|| bytes[..ED25519_MULTICODEC.len()] != ED25519_MULTICODEC
{
return Err(Error::Did("not an ed25519 did:key".to_string()));
}
let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..]
.try_into()
.expect("length checked above");
let key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))?;
// `from_bytes` only decompresses, so it accepts a small-order point. This
// crate parses did:key itself rather than going through gitlawb-core's
// `Did`, and it cannot go through it (gitlawb-core is a dev-dependency
// here), so the rejection is mirrored rather than inherited. Signature
// verification is already strict, so this is defense in depth against a
// future consumer of this parser that does not verify strictly.
if key.is_weak() {
return Err(Error::Did("small-order ed25519 key".to_string()));
}
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn fresh() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct DummyPayload {
agent: String,
commit: String,
}
impl AttestationPayload for DummyPayload {
fn payload_type() -> &'static str {
"test/dummy/v1"
}
}
fn sample_cert_hash() -> [u8; 32] {
let mut h = [0u8; 32];
for (i, b) in h.iter_mut().enumerate() {
*b = i as u8;
}
h
}
fn dummy_attestation(sk: &SigningKey, cert_hash: [u8; 32]) -> Attestation {
Attestation::sign(
sk,
DummyPayload {
agent: "a".to_string(),
commit: "b".to_string(),
},
cert_hash,
)
.unwrap()
}
#[test]
fn sign_then_verify_roundtrip() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let payload = DummyPayload {
agent: "did:key:z6MkTest".to_string(),
commit: "deadbeef".to_string(),
};
let att = Attestation::sign(&sk, payload.clone(), cert_hash).unwrap();
let vk = att.verify_signature(cert_hash).unwrap();
assert_eq!(vk.to_bytes(), sk.verifying_key().to_bytes());
assert_eq!(att.payload_as::<DummyPayload>().unwrap(), payload);
}
#[test]
fn cross_cert_replay_fails() {
let sk = fresh();
let cert_a = sample_cert_hash();
let mut cert_b = sample_cert_hash();
cert_b[0] ^= 0xff;
let att = dummy_attestation(&sk, cert_a);
let err = att.verify_signature(cert_b).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
#[test]
fn tampered_payload_fails_verify() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&sk, cert_hash);
att.payload = serde_json::json!({ "agent": "evil", "commit": "b" });
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
#[test]
fn payload_as_wrong_type_errors() {
let sk = fresh();
let att = dummy_attestation(&sk, sample_cert_hash());
#[derive(Debug, Serialize, Deserialize)]
struct Wrong {
#[allow(dead_code)]
x: String,
}
impl AttestationPayload for Wrong {
fn payload_type() -> &'static str {
"test/other/v1"
}
}
let err = att.payload_as::<Wrong>().unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn json_roundtrip_preserves_signature() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let att = dummy_attestation(&sk, cert_hash);
let json = serde_json::to_string(&att).unwrap();
let back: Attestation = serde_json::from_str(&json).unwrap();
back.verify_signature(cert_hash).unwrap();
}
/// Wire shape is part of the public protocol — pin field names so an
/// accidental rename surfaces as a test failure, not silent interop drift.
#[test]
fn wire_shape_pins_field_names() {
let sk = fresh();
let att = dummy_attestation(&sk, sample_cert_hash());
let v: serde_json::Value = serde_json::to_value(&att).unwrap();
let obj = v.as_object().expect("attestation must serialize as object");
let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
let expected: std::collections::BTreeSet<&str> =
["type", "payload", "cert_hash", "signer", "sig"]
.into_iter()
.collect();
assert_eq!(keys, expected);
assert!(obj["type"].is_string());
assert!(obj["cert_hash"].is_string());
assert!(obj["signer"].is_string());
assert!(obj["sig"].is_string());
}
/// The spec mandates lowercase hex on the wire. An uppercase cert_hash
/// is rejected as `CertHashMismatch` so a non-canonical signer can't
/// produce attestations that verify against some peers but not others.
#[test]
fn uppercase_cert_hash_is_rejected_as_non_canonical() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&sk, cert_hash);
att.cert_hash = att.cert_hash.to_uppercase();
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
#[test]
fn empty_type_rejected_at_signing() {
#[derive(Serialize, Deserialize)]
struct Empty {}
impl AttestationPayload for Empty {
fn payload_type() -> &'static str {
""
}
}
let sk = fresh();
let err = Attestation::sign(&sk, Empty {}, sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn validate_type_grammar() {
validate_type("covenant/exec/v1").unwrap();
validate_type("slsa/v1.0").unwrap();
validate_type("sigstore/dsse/v1").unwrap();
validate_type("a").unwrap();
validate_type("a.b-c_d+e/x").unwrap();
for bad in [
"",
" ",
"covenant/exec/v1 ",
"covenant//v1",
"/covenant/v1",
"covenant/v1/",
"covenant\x00exec/v1",
"covenant/exec\nv1",
"сovenant/exec/v1",
"covenant/ex ec/v1",
"covenant/exec/v1#frag",
] {
let err = validate_type(bad).unwrap_err();
assert!(matches!(err, Error::Type(_)), "must reject '{bad}'");
}
let too_long = "a/".repeat(70);
let err = validate_type(&too_long).unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn did_key_must_be_base58btc() {
// Sign with a real key, then re-encode the public-key bytes in a
// different multibase. Verification must reject.
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&sk.verifying_key().to_bytes());
for base in [
multibase::Base::Base64Url,
multibase::Base::Base32Lower,
multibase::Base::Base16Lower,
] {
att.signer = format!("did:key:{}", multibase::encode(base, &buf));
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)), "must reject base {base:?}");
}
}
#[test]
fn verify_rejects_did_without_did_key_prefix() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.signer = "ed25519:abc".to_string();
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_did_with_wrong_multicodec() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
// 0x12 0x00 = sha2-256 multicodec — wrong for an ed25519 public key.
let mut buf = vec![0x12, 0x00];
buf.extend_from_slice(&sk.verifying_key().to_bytes());
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_did_with_wrong_key_length() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
let mut buf = Vec::with_capacity(33);
buf.extend_from_slice(&ED25519_MULTICODEC);
// 31 bytes instead of 32.
buf.extend_from_slice(&sk.verifying_key().to_bytes()[..31]);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_oversized_method_id_before_decoding() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
// A well-formed prefix on an absurdly long id must fail the length
// bound, not the decoder: base58 decode cost is quadratic in input
// length, so reaching it at all is the defect.
att.signer = format!("did:key:z{}", "1".repeat(256));
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
// Match the bound's own message: any other Did error (a decode or
// length mismatch downstream) means the oversized input was decoded.
match err {
Error::Did(m) => assert!(m.contains("too long"), "unexpected: {m}"),
e => panic!("expected Error::Did, got {e:?}"),
}
}
#[test]
fn verify_rejects_bad_signature_base64() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.sig = "!!not-base64!!".to_string();
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
#[test]
fn verify_rejects_signature_wrong_length() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.sig = B64U.encode([0u8; 32]); // valid base64, wrong size.
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
/// A payload that happens to contain a `cert_hash` field of its own does
/// not interfere with the outer binding: the attestation envelope's
/// `cert_hash` is the only field consulted by `verify_signature`, and the
/// payload is treated as opaque JSON throughout. A reviewer worried about
/// shadowing or smuggling can verify this directly.
#[test]
fn payload_cert_hash_field_does_not_shadow_outer_binding() {
#[derive(Serialize, Deserialize)]
struct Sneaky {
cert_hash: String,
x: String,
}
impl AttestationPayload for Sneaky {
fn payload_type() -> &'static str {
"test/sneaky/v1"
}
}
let sk = fresh();
let outer_hash = sample_cert_hash();
let att = Attestation::sign(
&sk,
Sneaky {
cert_hash: "forged-by-the-attacker".to_string(),
x: "y".to_string(),
},
outer_hash,
)
.unwrap();
// Envelope's cert_hash is the outer (real) hash, lowercase hex.
assert_eq!(att.cert_hash, hex::encode(outer_hash));
// Payload retains its own field — opaque to the protocol.
assert_eq!(
att.payload.get("cert_hash").and_then(|v| v.as_str()),
Some("forged-by-the-attacker"),
);
// Verification binds to the outer envelope's cert_hash.
att.verify_signature(outer_hash).unwrap();
// And rejects when the outer cert hash differs.
let mut wrong = outer_hash;
wrong[0] ^= 0xff;
let err = att.verify_signature(wrong).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
/// A different protocol that also signs `{type, payload, cert_hash}` with
/// the same key would still fail attestation verification because the
/// signing input carries a domain tag the other protocol does not include.
#[test]
fn cross_protocol_replay_is_rejected_by_domain_separation() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let att = dummy_attestation(&sk, cert_hash);
// Sign the same JCS bytes WITHOUT the domain tag and graft the
// signature back onto the attestation.
let cert_hash_hex = hex::encode(cert_hash);
let bare = serde_jcs::to_vec(&SigningInput {
type_: &att.type_,
payload: &att.payload,
cert_hash: &cert_hash_hex,
})
.unwrap();
let other_sig: Signature = sk.sign(&bare);
let mut tampered = att.clone();
tampered.sig = B64U.encode(other_sig.to_bytes());
let err = tampered.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
/// Regression guard for strict verification: a signature forged under a
/// weak (small-order) public key satisfies the verification equation
/// but must be rejected. The identity point is such a key: with R the
/// identity and S = 0, [S]B - [k]A is the identity for any message.
/// gitlawb-attest parses did:key itself and never routes through
/// gitlawb-core's `Did`, which it cannot: gitlawb-core is a dev-dependency
/// here. So the choke-point rejection has to be mirrored in this parser or
/// this crate keeps handing out keys the rest of the system rejects.
#[test]
fn verifying_key_from_did_key_rejects_a_small_order_key() {
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&weak);
let did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err =
verifying_key_from_did_key(&did).expect_err("a small-order did:key must not resolve");
match err {
Error::Did(msg) => assert!(
msg.contains("small-order"),
"rejection must name the small-order key, got: {msg}"
),
other => panic!("expected Error::Did, got {other:?}"),
}
}
/// base58 decoding is quadratic in its input and `signer` is attacker
/// controlled, so an oversized method-id must be refused BEFORE the decode
/// or a hostile attestation buys a large decode for a short request. The
/// assertion is on the length error specifically: a multibase error here
/// would mean the cap ran too late to matter.
#[test]
fn verifying_key_from_did_key_refuses_an_oversized_method_id_before_decoding() {
let oversized = format!("did:key:z{}", "1".repeat(65_536));
let err = verifying_key_from_did_key(&oversized)
.expect_err("an oversized method-id must be refused");
match err {
Error::Did(msg) => assert!(
msg.contains("too long"),
"must fail on the length cap, not after decoding: {msg}"
),
other => panic!("expected Error::Did, got {other:?}"),
}
}
/// Strictness regression guard. The parser now refuses a small-order key,
/// so `verify_signature` can no longer carry a weak signer down to the
/// signature check; this drives that check directly to prove it is still
/// STRICT. Downgrading `verify_strict` to `verify` makes this go red,
/// which nothing else in the suite would catch.
#[test]
fn verify_sig_with_key_is_strict_about_a_weak_key() {
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak());
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
// R = identity, S = 0: satisfies the non-strict verification equation
// for any message under a small-order key.
let mut forged = [0u8; 64];
forged[0] = 1;
att.sig = B64U.encode(forged);
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
let err = att
.verify_sig_with_key(&weak_vk, &bytes)
.expect_err("strict verification must reject a small-order key");
assert!(
matches!(err, Error::Signature(_)),
"expected a signature error, got {err:?}"
);
}
/// Accept control for the seam: a genuine signature still verifies through
/// the same helper, so the guard above is not simply rejecting everything.
#[test]
fn verify_sig_with_key_accepts_a_genuine_signature() {
let cert_hash = sample_cert_hash();
let sk = SigningKey::generate(&mut OsRng);
let att = dummy_attestation(&sk, cert_hash);
let bytes = canonical_signing_bytes(&att.type_, &att.payload, &att.cert_hash).expect("jcs");
att.verify_sig_with_key(&sk.verifying_key(), &bytes)
.expect("a real signature must verify");
}
/// Control for the guard above: a real did:key must still resolve.
#[test]
fn verifying_key_from_did_key_still_accepts_a_real_key() {
let sk = SigningKey::from_bytes(&[3u8; 32]);
let vk = sk.verifying_key();
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&vk.to_bytes());
let did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let got = verifying_key_from_did_key(&did).expect("a real did:key must resolve");
assert_eq!(got.to_bytes(), vk.to_bytes());
}
#[test]
fn verify_rejects_weak_key_signature() {
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak(), "identity point must be a weak key");
let cert_hash = sample_cert_hash();
let mut forged = [0u8; 64];
forged[0] = 1; // R = identity point, S = 0
let forged_sig = B64U.encode(forged);
// Build an attestation with the weak key as signer and the forged
// signature. Rejection now happens at DID RESOLUTION rather than at
// verify time: `verifying_key_from_did_key` refuses a small-order key
// before `verify_strict` is reached, so the observed error is
// `Error::Did`, not `Error::Signature`. `verify_strict` remains the
// second layer for malleability cases that do not involve a weak
// public key (a small-order R under an honest key), which this fixture
// cannot construct and therefore no longer covers.
let mut att = dummy_attestation(&SigningKey::generate(&mut OsRng), cert_hash);
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&weak_key_bytes);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
att.sig = forged_sig;
let err = att.verify_signature(cert_hash).unwrap_err();
match err {
Error::Did(msg) => assert!(
msg.contains("small-order"),
"weak signer must be refused as a small-order key, got: {msg}"
),
other => panic!("expected Error::Did for a small-order signer, got {other:?}"),
}
}
}
-327
View File
@@ -1,327 +0,0 @@
//! Envelope around a `gitlawb/ref-update/v1` cert plus optional attestations,
//! and the cert-hash helper attestations bind to.
//!
//! An empty envelope serializes to the same bytes as the underlying bare cert.
//! `cert_hash` strips `signatures` and `attestations`, JCS-encodes the rest,
//! and SHA-256s; the result is what every attestation binds to.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::attestation::Attestation;
use crate::error::{Error, Result};
use crate::verifier::{Registry, VerifiedAttestation};
/// The discriminator the wrapped cert must carry. Matches the constant of the
/// same name in `gitlawb-core::cert`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
/// A ref-update cert with optional attestations.
///
/// The cert is flattened into the top-level JSON, so an empty envelope
/// produces the same bytes as a bare cert and existing decoders ignore the
/// extra `attestations` field. The cert is held as `serde_json::Value` so
/// this crate has no runtime dependency on `gitlawb-core::cert::RefUpdateCert`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestedRefUpdateCert {
/// The cert body and signatures, flattened to the top level.
#[serde(flatten)]
pub cert: serde_json::Value,
/// Attached attestations. Empty when absent on the wire.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attestations: Vec<Attestation>,
}
impl AttestedRefUpdateCert {
/// Wrap a cert. No attestations are attached yet.
///
/// The serialized cert must be a JSON object whose `type` field equals
/// [`CERT_TYPE`] and which does **not** already carry an `attestations`
/// field. The second rule catches two cases that would otherwise leave
/// the envelope in an inconsistent in-memory state:
///
/// - Double-wrap: `from_cert(&previously_wrapped_envelope)` would put the
/// inner attestations on `self.cert` and leave `self.attestations` empty.
/// - Pre-attested cert from a peer: a malformed or malicious cert with an
/// attached `attestations` field would survive `cert_hash` (which strips
/// the field) but bypass the wrapper's append-and-verify path entirely.
///
/// Both are rejected with [`Error::Payload`]. To re-wrap, deserialize the
/// envelope as `AttestedRefUpdateCert` directly.
pub fn from_cert<T: Serialize>(cert: &T) -> Result<Self> {
let value = serde_json::to_value(cert)?;
let obj = value
.as_object()
.ok_or_else(|| Error::Payload("cert must serialize as a JSON object".to_string()))?;
match obj.get("type").and_then(|v| v.as_str()) {
Some(t) if t == CERT_TYPE => {}
Some(other) => {
return Err(Error::Payload(format!(
"cert type must be '{CERT_TYPE}', got '{other}'"
)));
}
None => {
return Err(Error::Payload(format!(
"cert missing 'type' field (expected '{CERT_TYPE}')"
)));
}
}
if obj.contains_key("attestations") {
return Err(Error::Payload(
"cert already carries an 'attestations' field; deserialize as \
AttestedRefUpdateCert directly instead of re-wrapping"
.to_string(),
));
}
Ok(Self {
cert: value,
attestations: Vec::new(),
})
}
/// The 32-byte cert hash every attached attestation must bind to.
pub fn cert_hash(&self) -> Result<[u8; 32]> {
cert_hash(&self.cert)
}
/// Append an already-signed attestation.
pub fn attach(&mut self, attestation: Attestation) {
self.attestations.push(attestation);
}
/// Verify every attached attestation against this envelope's cert hash,
/// applying the registry's policy. Equivalent to
/// `registry.verify_all(&self.attestations, self.cert_hash()?)` but
/// reads as one operation at call sites.
pub fn verify_attestations(&self, registry: &Registry) -> Result<Vec<VerifiedAttestation>> {
registry.verify_all(&self.attestations, self.cert_hash()?)
}
}
/// SHA-256 of the cert body. The body must be a JSON object; `signatures` and
/// `attestations` are stripped before JCS encoding (RFC 8785), so the hash is
/// reproducible across languages and JSON libraries and is stable across
/// countersignatures.
pub fn cert_hash(cert: &serde_json::Value) -> Result<[u8; 32]> {
let mut body = cert.clone();
let obj = body
.as_object_mut()
.ok_or_else(|| Error::Payload("cert body must be a JSON object".to_string()))?;
obj.remove("signatures");
obj.remove("attestations");
let bytes = serde_jcs::to_vec(&body).map_err(|e| Error::Jcs(e.to_string()))?;
let mut h = Sha256::new();
h.update(&bytes);
let out = h.finalize();
let mut arr = [0u8; 32];
arr.copy_from_slice(&out);
Ok(arr)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn bare_cert() -> serde_json::Value {
json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1",
"signatures": [{"signer": "did:key:z6MkRepo", "sig": "abc"}]
})
}
#[test]
fn empty_envelope_serializes_as_bare_cert() {
let bare = bare_cert();
let env = AttestedRefUpdateCert::from_cert(&bare).unwrap();
let env_json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
let bare_keys: std::collections::BTreeSet<&String> =
bare.as_object().unwrap().keys().collect();
let env_keys: std::collections::BTreeSet<&String> =
env_json.as_object().unwrap().keys().collect();
assert_eq!(bare_keys, env_keys);
}
#[test]
fn bare_cert_parses_as_envelope_with_no_attestations() {
let bare = bare_cert();
let env: AttestedRefUpdateCert = serde_json::from_value(bare.clone()).unwrap();
assert!(env.attestations.is_empty());
assert_eq!(env.cert, bare);
}
#[test]
fn cert_hash_is_stable_across_countersignatures() {
let base_body = json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1"
});
let mut one_sig = base_body.clone();
one_sig["signatures"] = json!([{"signer": "x", "sig": "y"}]);
let mut two_sigs = base_body.clone();
two_sigs["signatures"] = json!([
{"signer": "x", "sig": "y"},
{"signer": "a", "sig": "b"}
]);
assert_eq!(cert_hash(&one_sig).unwrap(), cert_hash(&two_sigs).unwrap());
}
#[test]
fn cert_hash_changes_when_body_changes() {
let body_a = json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1",
"signatures": []
});
let mut body_b = body_a.clone();
body_b["to"] = json!("b".repeat(64));
assert_ne!(cert_hash(&body_a).unwrap(), cert_hash(&body_b).unwrap());
}
#[test]
fn cert_hash_rejects_non_object_root() {
let err = cert_hash(&json!(null)).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
let err = cert_hash(&json!([1, 2, 3])).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
let err = cert_hash(&json!("scalar")).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_wrong_type() {
let wrong = json!({"type": "gitlawb/ref-update/v0", "repo": "did:key:z"});
let err = AttestedRefUpdateCert::from_cert(&wrong).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_missing_type() {
let no_type = json!({"repo": "did:key:z"});
let err = AttestedRefUpdateCert::from_cert(&no_type).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_non_object() {
let arr = json!([1, 2, 3]);
let err = AttestedRefUpdateCert::from_cert(&arr).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn mutating_env_cert_changes_the_hash() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
let hash_before = env.cert_hash().unwrap();
env.cert.as_object_mut().unwrap().insert(
"nonce".to_string(),
serde_json::Value::String("mutated".to_string()),
);
let hash_after = env.cert_hash().unwrap();
assert_ne!(hash_before, hash_after);
}
/// A cert that already carries an `attestations` field is rejected
/// regardless of the field's contents. Catches double-wrap (where the
/// inner attestations would otherwise be smuggled into `self.cert`) and
/// peer-supplied "pre-attested" certs that would bypass the wrapper.
#[test]
fn from_cert_rejects_preexisting_attestations_field() {
let mut sneaky = bare_cert();
sneaky["attestations"] = json!([{"smuggled": true}]);
let err = AttestedRefUpdateCert::from_cert(&sneaky).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
// Empty array still rejected — the field's mere presence is the signal.
let mut empty = bare_cert();
empty["attestations"] = json!([]);
let err = AttestedRefUpdateCert::from_cert(&empty).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `from_cert` on an already-wrapped envelope is the same case as
/// `from_cert` on a cert with a pre-existing `attestations` field: serde
/// flattens the envelope back into a JSON object that carries the field.
#[test]
fn from_cert_rejects_a_previously_wrapped_envelope() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
// Inject an attestation-shaped value so the wire round-trip would
// actually re-emit the field.
env.cert
.as_object_mut()
.unwrap()
.insert("attestations".to_string(), json!([]));
let err = AttestedRefUpdateCert::from_cert(&env).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `attestations: null` is explicitly not the same as missing. The
/// `#[serde(default)]` attribute only fills in when the key is absent —
/// a present-but-null wire field is rejected, which surfaces malformed
/// payloads early instead of silently treating them as empty.
#[test]
fn attestations_null_on_wire_is_rejected() {
let mut with_null = bare_cert();
with_null["attestations"] = serde_json::Value::Null;
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_null).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("expected a sequence") || msg.contains("invalid type"),
"unexpected error: {msg}"
);
}
#[test]
fn attestations_string_on_wire_is_rejected() {
let mut with_str = bare_cert();
with_str["attestations"] = serde_json::Value::String("oops".to_string());
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_str).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected a sequence") || msg.contains("invalid type"));
}
/// `attach` does not deduplicate. Duplicate attestations are a verifier-
/// policy concern (e.g., "at most one SLSA provenance"), not a wrapper
/// concern. Pinning the behavior so a future deduplication change is an
/// intentional break.
#[test]
fn attach_does_not_deduplicate() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
let att = Attestation {
type_: "demo/v1".to_string(),
payload: serde_json::json!({}),
cert_hash: "deadbeef".to_string(),
signer: "did:key:z6MkDup".to_string(),
sig: "AAAA".to_string(),
};
env.attach(att.clone());
env.attach(att);
assert_eq!(env.attestations.len(), 2);
}
}
-46
View File
@@ -1,46 +0,0 @@
use thiserror::Error;
/// Errors from building or verifying an attestation.
#[derive(Debug, Error)]
pub enum Error {
/// Discriminator failed validation (empty, whitespace, control char,
/// disallowed grammar).
#[error("attestation type: {0}")]
Type(String),
/// Attestation was signed against a different cert.
#[error("cert hash mismatch")]
CertHashMismatch,
/// Ed25519 signature failed verification.
#[error("signature: {0}")]
Signature(String),
/// DID could not be parsed.
#[error("did: {0}")]
Did(String),
/// Payload structure check failed, or the cert body was not a JSON object.
#[error("payload: {0}")]
Payload(String),
/// No verifier registered for the type and policy required one.
#[error("no verifier registered for type '{0}'")]
UnknownType(String),
/// `RequireAll` policy did not find an entry for a required type, or the
/// entry was not fully verified.
#[error("required attestation type missing or unverified: '{0}'")]
RequiredMissing(String),
/// JSON failure.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// JCS encoding failure.
#[error("jcs: {0}")]
Jcs(String),
}
/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
-132
View File
@@ -1,132 +0,0 @@
//! Pluggable provenance attachments for Twigpine ref-update certs.
//!
//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to.
//! It does not say how the commit was produced. This crate adds an optional
//! `attestations` field that lets any provenance system (SLSA, Sigstore,
//! in-toto, agent runtimes) attach a typed, signed blob bound to the cert by
//! its hash.
//!
//! The cert format stays additive. A cert with no attestations serializes the
//! same bytes it does today, and existing decoders (which do not set
//! `deny_unknown_fields`) silently drop the new `attestations` field rather
//! than reject the cert.
//!
//! ## Wire shape
//!
//! ```json
//! {
//! "type": "gitlawb/ref-update/v1",
//! // ...standard cert fields...,
//! "signatures": [...],
//! "attestations": [
//! {
//! "type": "covenant/exec/v1",
//! "payload": { /* opaque, type-specific */ },
//! "cert_hash": "<sha256 hex of JCS-encoded cert body>",
//! "signer": "did:key:z6Mk...",
//! "sig": "<base64url ed25519 over the signing input>"
//! }
//! ]
//! }
//! ```
//!
//! ## Canonical bytes
//!
//! Hashing and signing inputs use JCS (RFC 8785) so they reproduce across
//! implementations regardless of struct field order or JSON library.
//!
//! The cert hash is `SHA-256(JCS(cert_body_without_signatures_or_attestations))`,
//! expressed as lowercase hex on the wire. Uppercase is non-canonical and
//! rejected on verify so the JCS signing input is uniquely determined by the
//! cert bytes alone — there is no implicit normalization rule that
//! cross-language signers might miss.
//!
//! The attestation signing input is the byte string
//! `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. The
//! domain tag is in the byte stream, not the canonical JSON, so an attestation
//! cannot be confused with a same-shape JCS document signed for a different
//! protocol that uses the same Ed25519 key.
//!
//! ## DID:key
//!
//! Signers are `did:key` DIDs over Ed25519, encoded with the multibase
//! base58btc (`z`) prefix per the W3C DID:key specification. Non-base58btc
//! encodings of the same public key are rejected on verify so allowlists,
//! deduplication, and signer-pinning policies that compare `signer` strings
//! stay consistent across peers.
//!
//! ## Verification
//!
//! [`Registry`] looks up a verifier by type discriminator. [`Policy`] decides
//! what to do when no verifier matches: `AcceptKnown` (default) lets unknown
//! types pass without trust; `RequireAll` enforces a per-repo allowlist while
//! still letting unrelated attached attestations through unverified (so a
//! third-party attachment cannot DoS the cert); `RejectUnknown` rejects
//! anything unregistered.
//!
//! ## Quick start
//!
//! ```
//! use ed25519_dalek::SigningKey;
//! use gitlawb_attest::{
//! Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert,
//! Error, Policy, Registry, Result, CERT_TYPE,
//! };
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize)]
//! struct ExecPayload { agent: String }
//! impl AttestationPayload for ExecPayload {
//! fn payload_type() -> &'static str { "covenant/exec/v1" }
//! }
//!
//! struct ExecVerifier;
//! impl AttestationVerifier for ExecVerifier {
//! fn type_(&self) -> &'static str { "covenant/exec/v1" }
//! fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
//! payload.get("agent").and_then(|v| v.as_str())
//! .ok_or_else(|| Error::Payload("missing agent".into()))?;
//! Ok(())
//! }
//! }
//!
//! # fn run() -> Result<()> {
//! let cert = serde_json::json!({
//! "type": CERT_TYPE, "repo": "did:key:z6MkRepo",
//! "ref_name": "refs/heads/main",
//! "from": "0".repeat(64), "to": "a".repeat(64),
//! "seq": 1, "timestamp": "2026-01-01T00:00:00Z", "nonce": "n1",
//! "signatures": []
//! });
//! let mut env = AttestedRefUpdateCert::from_cert(&cert)?;
//!
//! let sk = SigningKey::from_bytes(&[7u8; 32]);
//! let payload = ExecPayload { agent: "did:key:z6MkAgent".into() };
//! let att = Attestation::sign(&sk, payload, env.cert_hash()?)?;
//! env.attach(att);
//!
//! let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
//! registry.register(ExecVerifier);
//! let verified = env.verify_attestations(&registry)?;
//! assert_eq!(verified[0].type_, "covenant/exec/v1");
//! assert!(verified[0].fully_verified);
//! # Ok(()) }
//! # run().unwrap();
//! ```
pub mod attestation;
pub mod cert;
pub mod error;
pub mod verifier;
pub use attestation::{Attestation, AttestationPayload};
pub use cert::{cert_hash, AttestedRefUpdateCert, CERT_TYPE};
pub use error::{Error, Result};
pub use verifier::{AttestationVerifier, Policy, Registry, VerifiedAttestation};
/// Version string for the attestation envelope wire shape.
///
/// Bumped only when the binding rules — JCS input shape, domain-separation
/// tag, hash algorithm — change. Independent of [`CERT_TYPE`], which versions
/// the underlying ref-update cert.
pub const ATTEST_ENVELOPE_VERSION: &str = "v1";
-546
View File
@@ -1,546 +0,0 @@
//! Verifier trait, registry, and policy.
//!
//! A [`Registry`] maps a type discriminator to a verifier. [`Policy`] decides
//! what happens for types nothing is registered for: accept without trust,
//! reject, or require an allowlist.
//!
//! Under [`Policy::RequireAll`] the registry never short-circuits on unknown
//! types — any attestation can be attached by any third party, so blocking
//! the cert because an attacker added an unrelated `attacker/spam/v1` would
//! be a denial-of-service vector. Unknown types are silently treated as
//! `fully_verified = false`; the allowlist is enforced only by the
//! presence-and-verified check on `required_types` after the batch runs.
use std::collections::HashMap;
use crate::attestation::Attestation;
use crate::error::{Error, Result};
/// Verifier for one attestation type. Implementors declare the discriminator
/// they handle and validate the payload's shape. The signature and cert-hash
/// binding are checked by [`Registry::verify`] before `verify_payload` runs.
pub trait AttestationVerifier: Send + Sync {
/// The discriminator this verifier handles. Must be a `'static` literal
/// so the registry can key on it without copying.
fn type_(&self) -> &'static str;
/// Validate payload structure. Signature is already verified.
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()>;
}
/// What to do for attestation types with no registered verifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Policy {
/// Unknown types pass but are flagged `fully_verified = false`. Default.
#[default]
AcceptKnown,
/// Every entry in `required_types` must be present and fully verified.
/// Unknown types beyond the required set are still accepted unverified,
/// so a malicious extra attestation cannot DoS the cert.
RequireAll,
/// Reject every attestation whose type is not registered.
RejectUnknown,
}
/// Result of verifying one attestation.
#[derive(Debug, Clone)]
pub struct VerifiedAttestation {
/// Type discriminator.
pub type_: String,
/// Signer's `did:key`.
pub signer: String,
/// Lowercase SHA-256 hex of the cert body the attestation bound to.
pub cert_hash: String,
/// `true` when a verifier ran a payload check and accepted it. `false`
/// when the policy let an unknown type through without one.
pub fully_verified: bool,
}
/// Verifiers keyed by type discriminator.
#[derive(Default)]
pub struct Registry {
by_type: HashMap<&'static str, Box<dyn AttestationVerifier>>,
policy: Policy,
required_types: Vec<String>,
}
impl Registry {
/// Empty registry with the default policy.
pub fn new() -> Self {
Self::default()
}
/// Register a verifier by value. Returns the prior verifier for the same
/// type, if any, so callers can detect double-registration.
pub fn register<V: AttestationVerifier + 'static>(
&mut self,
verifier: V,
) -> Option<Box<dyn AttestationVerifier>> {
self.register_boxed(Box::new(verifier))
}
/// Like [`Self::register`] but takes an already-boxed trait object — useful
/// when verifiers are constructed dynamically.
pub fn register_boxed(
&mut self,
verifier: Box<dyn AttestationVerifier>,
) -> Option<Box<dyn AttestationVerifier>> {
let key = verifier.type_();
self.by_type.insert(key, verifier)
}
/// Switch policy.
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
/// Type discriminators that must be present and verified under
/// `Policy::RequireAll`. Ignored under other policies.
pub fn require_types<I, S>(mut self, types: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.required_types = types.into_iter().map(Into::into).collect();
self
}
/// Active policy.
pub fn policy(&self) -> Policy {
self.policy
}
/// Verify signature, cert-hash binding, and (if a verifier is registered)
/// payload structure. Under `RequireAll` this is lenient on unknown types
/// — see the module docstring.
pub fn verify(
&self,
attestation: &Attestation,
expected_cert_hash: [u8; 32],
) -> Result<VerifiedAttestation> {
// The key that actually verified is the trust anchor for the signer a
// consumer sees. `verify_signature` recovers it from the artifact's
// `signer` field and rejects a field that does not match the signing
// key, so this is the canonical DID of the key that signed — never a
// raw artifact string that could disagree with it.
let verified_key = attestation.verify_signature(expected_cert_hash)?;
let fully = match self.by_type.get(attestation.type_.as_str()) {
Some(v) => {
v.verify_payload(&attestation.payload)?;
true
}
None => match self.policy {
Policy::AcceptKnown | Policy::RequireAll => false,
Policy::RejectUnknown => {
return Err(Error::UnknownType(attestation.type_.clone()));
}
},
};
Ok(VerifiedAttestation {
type_: attestation.type_.clone(),
signer: crate::attestation::did_key_from_verifying_key(&verified_key),
cert_hash: attestation.cert_hash.clone(),
fully_verified: fully,
})
}
/// Verify a batch, then enforce `RequireAll`.
///
/// Under [`Policy::RequireAll`] this never short-circuits: an attestation
/// that fails to verify — bad signature, cert-hash mismatch, or malformed
/// payload, the actual `Err` paths of [`Registry::verify`] — is dropped
/// from the result rather than aborting the batch. A valid attestation of
/// a type with NO registered verifier is not one of those: under
/// `RequireAll` it verifies to `Ok` with `fully_verified: false` and stays
/// in the result, preserving the distinction between *untrusted* and
/// *invalid* (see `require_all_is_lenient_on_unknown_types_in_the_batch`).
/// Any third party can attach an attestation, so a hard error here would
/// let an unrelated `attacker/spam/v1` deny-of-service the whole cert (the
/// DoS vector the module docstring warns about). The `required_types`
/// check below is the real gate, and it only counts `fully_verified`
/// entries — so a required type represented solely by a malformed
/// attestation still fails with [`Error::RequiredMissing`].
///
/// Under `AcceptKnown` and `RejectUnknown` the batch stays strict: the
/// first verification error is surfaced to the caller.
pub fn verify_all(
&self,
attestations: &[Attestation],
expected_cert_hash: [u8; 32],
) -> Result<Vec<VerifiedAttestation>> {
if self.policy != Policy::RequireAll {
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
}
return Ok(verified);
}
// RequireAll: drop entries that fail to verify instead of aborting.
let verified: Vec<VerifiedAttestation> = attestations
.iter()
.filter_map(|a| self.verify(a, expected_cert_hash).ok())
.collect();
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
}
}
Ok(verified)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{did_key_from_verifying_key, Attestation, AttestationPayload};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn fresh() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Serialize, Deserialize)]
struct Demo {
a: String,
}
impl AttestationPayload for Demo {
fn payload_type() -> &'static str {
"demo/v1"
}
}
struct DemoVerifier;
impl AttestationVerifier for DemoVerifier {
fn type_(&self) -> &'static str {
"demo/v1"
}
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
payload
.get("a")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|_| ())
.ok_or_else(|| Error::Payload("missing or empty 'a'".to_string()))
}
}
/// A second verifier that has the same discriminator as `DemoVerifier` —
/// used to test last-wins on `register`.
struct DemoVerifierAlt;
impl AttestationVerifier for DemoVerifierAlt {
fn type_(&self) -> &'static str {
"demo/v1"
}
fn verify_payload(&self, _payload: &serde_json::Value) -> Result<()> {
Err(Error::Payload("alt always rejects".to_string()))
}
}
#[derive(Serialize, Deserialize)]
struct Other {
x: u32,
}
impl AttestationPayload for Other {
fn payload_type() -> &'static str {
"other/v1"
}
}
fn sample_hash() -> [u8; 32] {
let mut h = [0u8; 32];
for (i, b) in h.iter_mut().enumerate() {
*b = (i + 1) as u8;
}
h
}
fn signed_demo(sk: &SigningKey, cert_hash: [u8; 32], a: &str) -> Attestation {
Attestation::sign(sk, Demo { a: a.to_string() }, cert_hash).unwrap()
}
fn signed_other(sk: &SigningKey, cert_hash: [u8; 32], x: u32) -> Attestation {
Attestation::sign(sk, Other { x }, cert_hash).unwrap()
}
#[test]
fn registry_accepts_registered_type() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new();
reg.register(DemoVerifier);
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
assert!(v.fully_verified);
assert_eq!(v.type_, "demo/v1");
assert_eq!(v.cert_hash, hex::encode(cert_hash));
}
#[test]
fn accept_known_lets_unknown_pass_unverified() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new().with_policy(Policy::AcceptKnown);
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
assert!(!v.fully_verified);
}
#[test]
fn reject_unknown_blocks_unregistered_types() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new().with_policy(Policy::RejectUnknown);
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::UnknownType(_)));
}
#[test]
fn require_all_enforces_presence() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
let err = reg.verify_all(&[], cert_hash).unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify_all(&[att], cert_hash).unwrap();
assert_eq!(v.len(), 1);
}
/// Even when a junk attestation of an unrelated type is attached first, a
/// `RequireAll` registry must still accept the cert as long as the
/// required type is present and fully verified — the attacker shouldn't
/// be able to DoS the cert by attaching `attacker/spam/v1` before
/// `covenant/exec/v1`.
#[test]
fn require_all_is_lenient_on_unknown_types_in_the_batch() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
let spam = signed_other(&sk, cert_hash, 7);
let real = signed_demo(&sk, cert_hash, "ok");
let verified = reg.verify_all(&[spam, real], cert_hash).unwrap();
assert_eq!(verified.len(), 2);
assert!(verified
.iter()
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
assert!(verified
.iter()
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
}
/// The strict policies must surface a batch member's verification error
/// through `verify_all`, not launder it into a partial result. This binds
/// the POLICY BOUNDARY itself: only the `RequireAll` arm may drop failing
/// entries, and an apparently harmless cleanup that applies its
/// `filter_map(Result::ok)` shape to the strict arms would silently turn
/// the fail-closed contracts into fail-open — with every per-attestation
/// `verify` test still green, because none of them go through the batch
/// path. The failing entry is a wrong-cert-hash attestation (signed over a
/// different hash than the batch expects), a genuine `Err` under every
/// policy.
#[test]
fn strict_policies_propagate_a_batch_members_error() {
let sk = fresh();
let cert_hash = sample_hash();
let mut wrong_hash = sample_hash();
wrong_hash[0] ^= 0xff;
for policy in [Policy::AcceptKnown, Policy::RejectUnknown] {
let mut reg = Registry::new().with_policy(policy);
reg.register(DemoVerifier);
let good = signed_demo(&sk, cert_hash, "ok");
let bad = signed_demo(&sk, wrong_hash, "ok");
let err = reg
.verify_all(&[good, bad], cert_hash)
.expect_err("a strict policy must fail the whole batch");
assert!(
matches!(err, Error::Signature(_) | Error::CertHashMismatch),
"expected the underlying verification error, got {err:?}"
);
}
}
/// A required type that is present but unverified (no verifier registered
/// even though the type appears in `required_types`) must fail
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
/// the operator listed a type but forgot to register a verifier.
#[test]
fn require_all_rejects_present_but_unverified_required_type() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]); // type listed, but no verifier registered.
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify_all(&[att], cert_hash).unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
/// A cert-hash that does not match `sample_hash()`, so an attestation
/// signed against it fails `verify_signature` when the batch is checked
/// against `sample_hash()`.
fn other_hash() -> [u8; 32] {
let mut h = sample_hash();
h[0] ^= 0xff;
h
}
/// Under `RequireAll`, a malformed attestation (here: signed against the
/// wrong cert hash, so it fails signature verification) attached alongside a
/// valid required one must not abort the batch. The junk is dropped and the
/// cert still verifies. Before the fix, `verify_all` propagated the error
/// via `?` and the whole batch failed — the DoS vector the docstring warns
/// about.
#[test]
fn require_all_drops_a_malformed_extra_and_still_accepts() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// Signed against `other_hash()`, so it will not verify under `cert_hash`.
let malformed = signed_demo(&sk, other_hash(), "boom");
let real = signed_demo(&sk, cert_hash, "ok");
let verified = reg
.verify_all(&[malformed, real], cert_hash)
.expect("a malformed extra must not abort the batch under RequireAll");
assert_eq!(verified.len(), 1, "the malformed entry should be dropped");
assert!(verified
.iter()
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
}
/// Leniency must not open a hole: if the *only* attestation of a required
/// type is malformed, dropping it leaves the required type absent and
/// `verify_all` must still fail with `RequiredMissing`. Guards against a fix
/// that skips errors so eagerly it lets an unverified required type pass.
#[test]
fn require_all_rejects_when_only_a_malformed_required_type_is_present() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
// The sole demo/v1 is bound to the wrong cert hash → fails verification.
let malformed_required = signed_demo(&sk, other_hash(), "ok");
let err = reg
.verify_all(&[malformed_required], cert_hash)
.unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
#[test]
fn payload_check_failure_rejects() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new();
reg.register(DemoVerifier);
// Sign an empty `a` so the signature verifies but the payload check fails.
let att = Attestation::sign(&sk, Demo { a: String::new() }, cert_hash).unwrap();
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `RequireAll` with an empty allowlist degenerates to "no required types
/// to check" — the loop over `required_types` is a no-op, so an empty
/// batch verifies successfully. Pinned so operators relying on the empty
/// allowlist as a default-permissive RequireAll see consistent behavior.
#[test]
fn require_all_with_empty_allowlist_and_empty_batch_succeeds() {
let reg = Registry::new().with_policy(Policy::RequireAll);
let verified = reg.verify_all(&[], sample_hash()).unwrap();
assert!(verified.is_empty());
}
#[test]
fn register_returns_the_prior_verifier_on_double_register() {
let mut reg = Registry::new();
assert!(reg.register(DemoVerifier).is_none());
let prior = reg.register(DemoVerifierAlt);
let prior = prior.expect("second register must return the previous verifier");
assert_eq!(prior.type_(), "demo/v1");
// Last-wins semantics confirmed by behavior.
let sk = fresh();
let cert_hash = sample_hash();
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// The signer a consumer sees must be the canonical DID of the key that
/// actually verified, never a raw artifact string that could disagree.
/// `verify_signature` derives the key from the artifact's `signer` field
/// and the parser is canonical (base58btc, exact length, ed25519
/// multicodec), so a forged field fails verification outright: the
/// must-not here is that no `VerifiedAttestation` ever carries a signer
/// that did not verify.
#[test]
fn verified_signer_is_anchored_to_the_key_that_verified() {
let signer = fresh();
let other = fresh();
assert_ne!(
signer.verifying_key().to_bytes(),
other.verifying_key().to_bytes(),
"fixture precondition: distinct signing keys"
);
let cert_hash = sample_hash();
let reg = Registry::new();
// Control: an honest artifact reports the signer's canonical DID, and
// it agrees with the artifact field.
let att = signed_demo(&signer, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
let expected = did_key_from_verifying_key(&signer.verifying_key());
assert_eq!(v.signer, expected, "signer must be the verified key's DID");
assert_eq!(v.signer, att.signer, "honest artifact: field and key agree");
// Must-not, both directions: a signer field naming a DIFFERENT key
// than the one that signed must fail verification, so the artifact
// string can never redirect the reported signer.
let mut forged = signed_demo(&signer, cert_hash, "ok");
forged.signer = did_key_from_verifying_key(&other.verifying_key());
let err = reg.verify(&forged, cert_hash).unwrap_err();
assert!(
matches!(err, Error::Signature(_)),
"a signer field that does not match the signing key must fail, got {err:?}"
);
let mut forged_rev = signed_demo(&other, cert_hash, "ok");
forged_rev.signer = did_key_from_verifying_key(&signer.verifying_key());
let err_rev = reg.verify(&forged_rev, cert_hash).unwrap_err();
assert!(
matches!(err_rev, Error::Signature(_)),
"mismatched signer field must fail verification, got {err_rev:?}"
);
}
}
@@ -1,150 +0,0 @@
//! Canonical test vectors.
//!
//! Ed25519 signatures are deterministic (RFC 8032). JCS canonicalization is
//! deterministic (RFC 8785). SHA-256 is deterministic. Given fixed inputs the
//! cert hash, signature, signer DID, and full wire JSON are therefore exactly
//! reproducible across implementations. This file pins those values so a
//! port to Go, JS, Python, or any other language has a concrete oracle to
//! check against without reading Rust.
//!
//! If a vector changes, that is a wire-protocol break. The change is either
//! intentional (bump `ATTEST_ENVELOPE_VERSION` and document the migration) or
//! a regression that broke interoperability with every other implementation.
use ed25519_dalek::SigningKey;
use gitlawb_attest::{Attestation, AttestationPayload, AttestedRefUpdateCert, Registry, CERT_TYPE};
use serde::{Deserialize, Serialize};
/// Fixed seed for the attestation signer. Real signers use a CSPRNG; this is a
/// test-vector convention only.
const SIGNER_SEED: [u8; 32] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
];
/// Fixed cert body for the vectors. Mirrors the field layout of a
/// `gitlawb-core::cert::RefUpdateBody` plus a `signatures` array. The
/// signatures array is deliberately non-empty to test that it is stripped
/// before hashing.
fn fixture_cert_body() -> serde_json::Value {
serde_json::json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt",
"ref_name": "refs/heads/main",
"from": "0000000000000000000000000000000000000000000000000000000000000000",
"to": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "vector-nonce-1",
"signatures": [
{"signer": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt", "sig": "ignored"}
]
})
}
/// Fixed attestation payload. Shape mirrors what Covenant's runtime would
/// produce for a `covenant/exec/v1` attestation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct ExecPayload {
agent: String,
capability: String,
sandbox_digest: String,
}
impl AttestationPayload for ExecPayload {
fn payload_type() -> &'static str {
"covenant/exec/v1"
}
}
fn fixture_payload() -> ExecPayload {
ExecPayload {
agent: "did:key:z6MkAgentTestVector".to_string(),
capability: "code.write+sandbox.exec".to_string(),
sandbox_digest: "sha256:c0ffee".to_string(),
}
}
/// The expected lowercase-hex SHA-256 of `JCS(cert_body \ {signatures})`.
/// Any port that produces a different value is not JCS-canonicalizing the
/// same way (likely culprits: key ordering, integer or string escaping).
const EXPECTED_CERT_HASH_HEX: &str =
"5898d7c7a7d32a5faaaf9a52bfc92e0eff98027554adeefba0c575816f43ce56";
/// The expected `did:key` of the signer derived from `SIGNER_SEED`. Any port
/// that produces a different value has a multibase or multicodec encoding bug.
const EXPECTED_SIGNER_DID: &str = "did:key:z6MkqkKY69DYo23W4YFDoCNgjr7cMvmJqHtJVoQkEzrbaopZ";
/// The expected base64url-no-pad Ed25519 signature over
/// `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. Any port
/// that produces a different value is missing the domain tag, JCS-encoding
/// the input differently, or using a different Ed25519 implementation.
const EXPECTED_SIGNATURE_B64URL: &str =
"4L3ONbhrksB9pn--u86lMeAdePSiZZplAsxuN2Cy6vgI30ndle7gDUVuoGRJr4Ylhz7ipQvUzXaDTvZGFLPXBQ";
#[test]
fn cert_hash_is_deterministic_from_jcs() {
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let actual_hex = hex::encode(env.cert_hash().unwrap());
assert_eq!(
actual_hex, EXPECTED_CERT_HASH_HEX,
"cert hash drifted — JCS encoding or hash algorithm changed",
);
}
#[test]
fn signer_did_is_deterministic_from_seed() {
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
assert_eq!(
att.signer, EXPECTED_SIGNER_DID,
"signer DID drifted — multibase or multicodec encoding changed",
);
}
#[test]
fn attestation_signature_is_deterministic() {
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
assert_eq!(
att.sig, EXPECTED_SIGNATURE_B64URL,
"signature drifted — domain tag, JCS shape, or Ed25519 implementation changed",
);
}
#[test]
fn vector_attestation_verifies_through_the_public_api() {
// Round-trip the entire pipeline using only the public API on the fixed
// vectors. A non-Rust implementor should be able to:
// 1. Compute cert_hash and check against EXPECTED_CERT_HASH_HEX.
// 2. Derive signer DID and check against EXPECTED_SIGNER_DID.
// 3. Sign a payload identical to fixture_payload() and check against
// EXPECTED_SIGNATURE_B64URL.
// 4. Reconstruct the wire envelope and verify it round-trips.
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let mut env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
env.attach(att);
let wire = serde_json::to_string(&env).unwrap();
let back: AttestedRefUpdateCert = serde_json::from_str(&wire).unwrap();
assert_eq!(back.attestations.len(), 1);
assert_eq!(back.attestations[0].sig, EXPECTED_SIGNATURE_B64URL);
let recovered = back.attestations[0]
.verify_signature(back.cert_hash().unwrap())
.unwrap();
assert_eq!(recovered.to_bytes(), sk.verifying_key().to_bytes());
// And through a registry, with the convenience method.
let reg = Registry::new();
let verified = back.verify_attestations(&reg).unwrap();
assert_eq!(verified.len(), 1);
assert_eq!(verified[0].cert_hash, EXPECTED_CERT_HASH_HEX);
assert_eq!(verified[0].signer, EXPECTED_SIGNER_DID);
}
@@ -1,251 +0,0 @@
//! End-to-end integration with `gitlawb-core`.
//!
//! Proves the attest envelope composes with the real `RefUpdateCert` from
//! `gitlawb-core`: a cert built and signed through the core API wraps cleanly,
//! survives signing and verifying an attestation, and round-trips back to a
//! `RefUpdateCert` that core still verifies. None of this is exercised by the
//! unit tests in `cert.rs`, which use hand-written JSON literals to keep the
//! crate's runtime deps minimal.
use ed25519_dalek::SigningKey;
use gitlawb_attest::{
Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert, Error, Policy,
Registry, Result as AttestResult,
};
use gitlawb_core::cert::RefUpdateCert;
use gitlawb_core::identity::Keypair;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn zero_hash() -> String {
"0".repeat(64)
}
fn commit_hash(c: char) -> String {
c.to_string().repeat(64)
}
fn fresh_signing_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct CovenantExecPayload {
agent: String,
capability: String,
sandbox_digest: String,
}
impl AttestationPayload for CovenantExecPayload {
fn payload_type() -> &'static str {
"covenant/exec/v1"
}
}
struct CovenantExecVerifier;
impl AttestationVerifier for CovenantExecVerifier {
fn type_(&self) -> &'static str {
"covenant/exec/v1"
}
fn verify_payload(&self, payload: &serde_json::Value) -> AttestResult<()> {
for field in ["agent", "capability", "sandbox_digest"] {
payload
.get(field)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::Payload(format!("missing or empty '{field}'")))?;
}
Ok(())
}
}
#[test]
fn wraps_real_cert_signs_and_verifies_attestation_then_unwraps_cleanly() {
// 1. Build a real cert through gitlawb-core. The pusher is the repo owner
// in this minimal setup; the cert is single-signature.
let pusher = Keypair::generate();
let repo_did = pusher.did();
let cert = RefUpdateCert::new(
repo_did,
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('a'),
1,
&pusher,
)
.unwrap();
cert.validate_structure().unwrap();
let valid_signers = cert.verify_all().unwrap();
assert_eq!(valid_signers.len(), 1);
// 2. Wrap. With no attestations attached the envelope is byte-identical to
// the underlying cert — that's the additive-on-the-wire promise.
let cert_json = serde_json::to_value(&cert).unwrap();
let mut env = AttestedRefUpdateCert::from_cert(&cert).unwrap();
assert_eq!(
serde_json::to_value(&env).unwrap(),
cert_json,
"empty envelope must serialize as the bare cert"
);
// 3. A separate party (here, an agent runtime) attests to how the commit
// was produced. The attestation signer is intentionally not the cert
// pusher: trust in the attestation is evaluated independently of trust
// in the push.
let agent_signer = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent".to_string(),
capability: "code.write+sandbox.exec".to_string(),
sandbox_digest: "sha256:c0ffee".to_string(),
};
let cert_hash = env.cert_hash().unwrap();
let attestation = Attestation::sign(&agent_signer, payload.clone(), cert_hash).unwrap();
env.attach(attestation);
// 4. Verify through a registry that knows `covenant/exec/v1`. Strict
// policy so an unknown discriminator would have been rejected.
let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
registry.register(CovenantExecVerifier);
let verified = registry
.verify_all(&env.attestations, env.cert_hash().unwrap())
.unwrap();
assert_eq!(verified.len(), 1);
assert!(verified[0].fully_verified);
assert_eq!(verified[0].type_, "covenant/exec/v1");
assert_eq!(verified[0].cert_hash, hex::encode(cert_hash));
// 5. Round-trip through JSON and back. The wrapped cert with one
// attestation must parse back into the same envelope, and stripping
// the attestations must yield a `RefUpdateCert` core still verifies.
let on_wire = serde_json::to_string(&env).unwrap();
let back: AttestedRefUpdateCert = serde_json::from_str(&on_wire).unwrap();
assert_eq!(back.attestations.len(), 1);
assert_eq!(
back.attestations[0]
.payload_as::<CovenantExecPayload>()
.unwrap(),
payload
);
let mut unwrapped = back.cert.clone();
if let Some(obj) = unwrapped.as_object_mut() {
obj.remove("attestations");
}
let unwrapped_cert: RefUpdateCert = serde_json::from_value(unwrapped).unwrap();
let revalidated = unwrapped_cert.verify_all().unwrap();
assert_eq!(revalidated, valid_signers);
}
#[test]
fn cert_hash_is_stable_when_a_maintainer_countersigns() {
// The cert hash strips `signatures` before hashing, so countersignatures
// added after an attestation is signed must not invalidate the
// attestation's binding.
let pusher = Keypair::generate();
let maintainer = Keypair::generate();
let mut cert = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('b'),
7,
&pusher,
)
.unwrap();
let env_before = AttestedRefUpdateCert::from_cert(&cert).unwrap();
let hash_before = env_before.cert_hash().unwrap();
cert.countersign(&maintainer).unwrap();
let env_after = AttestedRefUpdateCert::from_cert(&cert).unwrap();
let hash_after = env_after.cert_hash().unwrap();
assert_eq!(
hash_before, hash_after,
"cert hash must be stable across countersignatures"
);
// An attestation signed against the pre-countersign hash still verifies
// against the post-countersign cert.
let agent = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent2".to_string(),
capability: "code.write".to_string(),
sandbox_digest: "sha256:deadbeef".to_string(),
};
let attestation = Attestation::sign(&agent, payload, hash_before).unwrap();
attestation.verify_signature(hash_after).unwrap();
}
#[test]
fn cross_cert_replay_is_rejected_against_a_real_cert() {
// Negative case: an attestation signed for one cert must not verify on a
// different cert built through the core API.
let pusher = Keypair::generate();
let cert_a = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('a'),
1,
&pusher,
)
.unwrap();
let cert_b = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
commit_hash('a'),
commit_hash('b'),
2,
&pusher,
)
.unwrap();
let env_a = AttestedRefUpdateCert::from_cert(&cert_a).unwrap();
let env_b = AttestedRefUpdateCert::from_cert(&cert_b).unwrap();
let hash_a = env_a.cert_hash().unwrap();
let hash_b = env_b.cert_hash().unwrap();
assert_ne!(hash_a, hash_b);
let agent = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent3".to_string(),
capability: "code.write".to_string(),
sandbox_digest: "sha256:cafe".to_string(),
};
let attestation = Attestation::sign(&agent, payload, hash_a).unwrap();
let err = attestation.verify_signature(hash_b).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
/// A cert deserialized from JSON and a cert built through the core API must
/// hash identically. JCS canonicalization is what makes the additive-on-wire
/// promise hold across the serialize/deserialize boundary — without it, the
/// hash would depend on which side built the `serde_json::Value`.
#[test]
fn cert_hash_is_identical_across_the_deserialize_boundary() {
let pusher = Keypair::generate();
let cert = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('c'),
9,
&pusher,
)
.unwrap();
let direct_hash = AttestedRefUpdateCert::from_cert(&cert)
.unwrap()
.cert_hash()
.unwrap();
let wire = serde_json::to_string(&cert).unwrap();
let parsed_value: serde_json::Value = serde_json::from_str(&wire).unwrap();
let parsed_env: AttestedRefUpdateCert = serde_json::from_value(parsed_value).unwrap();
let parsed_hash = parsed_env.cert_hash().unwrap();
assert_eq!(direct_hash, parsed_hash);
}
+2 -13
View File
@@ -1,9 +1,8 @@
[package]
name = "gitlawb-core"
description = "Core cryptographic primitives for the Twigpine network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version = "0.7.1" # x-release-please-version
description = "Core cryptographic primitives for the gitlawb network: DIDs, CIDs, UCAN, HTTP Signatures, ref-update certificates"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
@@ -22,18 +21,8 @@ multihash-codetable = { workspace = true }
cid = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
url = { workspace = true, optional = true }
zeroize = { version = "1", features = ["derive"] }
pkcs8 = { version = "0.10", features = ["pem", "std"] }
curve25519-dalek = "4"
crypto_box = { version = "0.9", features = ["std", "chacha20"] }
chacha20poly1305 = "0.10"
[dev-dependencies]
tokio = { workspace = true }
# Not optional here: the redirect matrix must run under a bare
# `cargo test -p gitlawb-core`, with no feature selected.
url = { workspace = true }
[features]
redirect = ["dep:url"]
+16 -152
View File
@@ -6,15 +6,13 @@
//! The schema is frozen at v1. All fields are mandatory for forward compatibility.
//! Nodes that receive a certificate with an unknown version MUST reject it.
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::did::Did;
use crate::identity::{verify, Keypair};
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::{Keypair, verify};
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
@@ -112,7 +110,7 @@ impl RefUpdateCert {
///
/// Returns the list of DIDs whose signatures are valid.
pub fn verify_all(&self) -> Result<Vec<Did>> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let signing_bytes = self.body.to_signing_bytes()?;
let mut valid = Vec::new();
@@ -120,12 +118,10 @@ impl RefUpdateCert {
// Resolve the verifying key from the DID
let vk = cert_sig.signer.to_verifying_key()?;
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&cert_sig.sig)
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&cert_sig.sig)
.map_err(|e| Error::RefCert(format!("invalid base64 sig: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
.map_err(|_| Error::RefCert("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)?;
@@ -137,14 +133,14 @@ impl RefUpdateCert {
/// Check if this certificate satisfies a threshold of valid signatures
/// from the provided set of authorized maintainer DIDs.
///
/// Counts distinct signer DIDs, not signature entries: a repeated
/// signature from the same maintainer counts once.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
pub fn satisfies_threshold(
&self,
maintainers: &[Did],
threshold: usize,
) -> Result<bool> {
let valid = self.verify_all()?;
let distinct_signers: HashSet<&Did> =
valid.iter().filter(|d| maintainers.contains(d)).collect();
Ok(distinct_signers.len() >= threshold)
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
Ok(count >= threshold)
}
/// Validate the certificate structure (not signatures).
@@ -191,8 +187,7 @@ mod tests {
dummy_hash('a'),
1,
&kp,
)
.unwrap();
).unwrap();
cert.validate_structure().unwrap();
let valid = cert.verify_all().unwrap();
@@ -213,8 +208,7 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
).unwrap();
cert.countersign(&kp2).unwrap();
let valid = cert.verify_all().unwrap();
@@ -234,8 +228,7 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
).unwrap();
cert.countersign(&kp2).unwrap();
let maintainers = vec![kp1.did(), kp2.did()];
@@ -253,8 +246,7 @@ mod tests {
dummy_hash('b'),
42,
&kp,
)
.unwrap();
).unwrap();
let json = serde_json::to_string_pretty(&cert).unwrap();
assert!(json.contains("gitlawb/ref-update/v1"));
@@ -263,132 +255,4 @@ mod tests {
// Round-trip
let _: RefUpdateCert = serde_json::from_str(&json).unwrap();
}
#[test]
fn validate_structure_rejects_wrong_cert_type() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.body.type_ = "gitlawb/ref-update/v0".to_string();
assert!(cert.validate_structure().is_err());
}
#[test]
fn validate_structure_rejects_invalid_to_hash() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.body.to = "not-a-valid-sha256".to_string();
assert!(cert.validate_structure().is_err());
}
#[test]
fn validate_structure_rejects_empty_signatures() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.signatures.clear();
assert!(cert.validate_structure().is_err());
}
#[test]
fn tampered_signature_fails_verify_all() {
let kp = Keypair::generate();
let kp2 = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
// Replace kp's signature with a signature from kp2 over different bytes.
// The signer DID still points to kp, so verification must fail.
cert.signatures[0].sig = kp2.sign_b64(b"unrelated bytes");
assert!(cert.verify_all().is_err());
}
#[test]
fn threshold_not_met_when_signer_outside_maintainer_list() {
let kp_pusher = Keypair::generate();
let kp_maintainer = Keypair::generate();
let cert = RefUpdateCert::new(
kp_pusher.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp_pusher,
)
.unwrap();
let maintainers = vec![kp_maintainer.did()];
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
}
#[test]
fn satisfies_threshold_rejects_duplicated_signature() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let kp3 = Keypair::generate();
let repo_did = kp1.did();
let mut cert = RefUpdateCert::new(
repo_did,
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
// Copy-paste the only real signature onto the certificate a second
// time. Same signer, same valid signature, still one real signer.
let dup = cert.signatures[0].clone();
cert.signatures.push(dup);
let maintainers = vec![kp1.did(), kp2.did(), kp3.did()];
// Two signature entries but a single distinct signer must not
// satisfy a 2-of-3 threshold.
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
}
#[test]
fn threshold_zero_is_always_satisfied() {
let kp = Keypair::generate();
let cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
assert!(cert.satisfies_threshold(&[], 0).unwrap());
}
}
+5 -110
View File
@@ -11,7 +11,7 @@
use cid::CidGeneric;
use multihash_codetable::{Code, MultihashDigest};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sha2::{Sha256, Digest};
use std::fmt;
use crate::{Error, Result};
@@ -64,19 +64,6 @@ impl Cid {
}
}
/// True when `s` parses as a CIDv1 with the raw codec — the exact shape
/// [`Cid::from_git_object_bytes`] produces and the `/ipfs` resolver looks up.
/// A legacy provider CID (Kubo dag-pb, Pinata CIDv0) parses to a different
/// version or codec and returns `false`, marking it an opportunistic-repair
/// candidate. Decidable from the string alone (no object bytes), so the pin path
/// can gate the byte-read/recompute cost on it and leave non-legacy rows at the
/// existing DB-only skip cost. An unparseable string is non-canonical (`false`).
pub fn is_raw_cidv1(s: &str) -> bool {
s.parse::<CidGeneric<64>>()
.map(|c| c.version() == cid::Version::V1 && c.codec() == RAW)
.unwrap_or(false)
}
impl fmt::Display for Cid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
@@ -100,9 +87,9 @@ pub fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
/// Parse a 64-character hex SHA-256 string into raw bytes.
pub fn sha256_hex_to_bytes(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode(hex_str).map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes
.try_into()
let bytes = hex::decode(hex_str)
.map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes.try_into()
.map_err(|_| Error::InvalidCid("sha256 hash must be 32 bytes (64 hex chars)".to_string()))
}
@@ -123,10 +110,7 @@ mod tests {
// CIDv1 base32 strings start with 'b'
let data = b"blob 13\0hello gitlawb";
let c = Cid::from_git_object_bytes(data);
assert!(
c.to_string().starts_with('b'),
"CIDv1 should be base32 (starts with 'b')"
);
assert!(c.to_string().starts_with('b'), "CIDv1 should be base32 (starts with 'b')");
}
#[test]
@@ -142,93 +126,4 @@ mod tests {
let bytes = sha256_hex_to_bytes(&hex).unwrap();
assert_eq!(sha256_bytes(data), bytes);
}
#[test]
fn from_sha256_bytes_matches_object_bytes() {
// from_sha256_bytes must produce the same CID as from_git_object_bytes
// when given the SHA-256 of those bytes — the two construction paths
// must be equivalent for self-verifying content addressing to hold.
let data = b"blob 5\0hello";
let cid_from_object = Cid::from_git_object_bytes(data);
let hash = sha256_bytes(data);
let cid_from_hash = Cid::from_sha256_bytes(&hash);
assert_eq!(cid_from_object, cid_from_hash);
}
#[test]
fn from_str_parses_valid_cid() {
let data = b"tree content for test";
let cid = Cid::from_git_object_bytes(data);
let parsed = Cid::from_str(cid.as_str()).unwrap();
assert_eq!(cid, parsed);
}
#[test]
fn from_str_rejects_invalid_string() {
let result = Cid::from_str("not-a-valid-cid");
assert!(result.is_err());
}
#[test]
fn different_data_produces_different_cids() {
let c1 = Cid::from_git_object_bytes(b"blob 5\0hello");
let c2 = Cid::from_git_object_bytes(b"blob 5\0world");
assert_ne!(c1, c2);
}
#[test]
fn sha256_hex_to_bytes_rejects_non_hex() {
let non_hex = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
assert_eq!(non_hex.len(), 64);
let result = sha256_hex_to_bytes(non_hex);
assert!(result.is_err());
}
#[test]
fn sha256_hex_to_bytes_rejects_wrong_length() {
let result = sha256_hex_to_bytes("deadbeef");
assert!(result.is_err());
}
#[test]
fn is_raw_cidv1_classifies_codec_from_string() {
// The canonical resolver key: CIDv1 + raw codec → not a repair candidate.
let raw = Cid::from_git_object_bytes(b"blob 5\0hello");
assert!(
is_raw_cidv1(raw.as_str()),
"from_git_object_bytes output is CIDv1/raw"
);
// A CIDv0 (Pinata dag-pb legacy shape) → repair candidate.
assert!(
!is_raw_cidv1("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"),
"a CIDv0 dag-pb value is a legacy-repair candidate"
);
// A CIDv1 with the dag-pb codec (the Kubo above-block-size root) over the
// same multihash → still a repair candidate (codec, not just version).
let parsed = raw.as_str().parse::<CidGeneric<64>>().unwrap();
const DAG_PB: u64 = 0x70;
let dagpb = CidGeneric::<64>::new_v1(DAG_PB, *parsed.hash()).to_string();
assert!(
!is_raw_cidv1(&dagpb),
"a CIDv1 dag-pb value is a legacy-repair candidate"
);
// Garbage is non-canonical.
assert!(
!is_raw_cidv1("not-a-cid"),
"an unparseable string is non-canonical"
);
}
#[test]
fn sha256_hex_of_empty_input_is_well_known() {
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
let h = sha256_hex(b"");
assert_eq!(
h,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}
+11 -183
View File
@@ -1,11 +1,11 @@
//! Decentralized Identifier (DID) types for the Twigpine network.
//! Decentralized Identifier (DID) types for the gitlawb network.
//!
//! Supported methods:
//! - `did:key` — ephemeral, derived directly from an Ed25519 public key
//! - `did:web` — anchored to a domain
//! - `did:gitlawb` — native, anchored to the libp2p DHT
//!
//! The canonical DID for a Twigpine actor is `did:key` during bootstrap
//! The canonical DID for a gitlawb actor is `did:key` during bootstrap
//! and migrates to `did:gitlawb` once DHT anchoring is live.
use ed25519_dalek::VerifyingKey;
@@ -71,31 +71,16 @@ impl Did {
/// Resolve the Ed25519 verifying key from a `did:key`.
///
/// Returns `Err` if the DID is not a `did:key`, the key bytes are malformed,
/// or the key is a small-order (weak) point.
/// Returns `Err` if the DID is not a `did:key` or the key bytes are invalid.
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
if !self.is_did_key() {
return Err(Error::InvalidDid(format!(
"expected did:key, got did:{}",
self.method()
"expected did:key, got did:{}", self.method()
)));
}
// Refuse an oversized id before decoding. base58 decoding is quadratic
// in its input, and both callers of this function take the string from
// an untrusted request: the unauthenticated peer-announce gate, and the
// HTTP-signature keyid. An ed25519 did:key method-id is a fixed 48
// characters, so this bound is slack rather than a behavior change, and
// it has to sit ahead of the decode to be worth anything.
const MAX_METHOD_ID_LEN: usize = 64;
if self.method_id().len() > MAX_METHOD_ID_LEN {
return Err(Error::InvalidDid(
"did:key method-specific id too long".to_string(),
));
}
let (_, bytes) =
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
let (_, bytes) = multibase::decode(self.method_id())
.map_err(|e| Error::InvalidDid(e.to_string()))?;
if !bytes.starts_with(ED25519_MULTICODEC) {
return Err(Error::InvalidDid(
@@ -107,25 +92,8 @@ impl Did {
.try_into()
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
let key =
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))?;
// `from_bytes` only decompresses, so it accepts a small-order point.
// Such a key satisfies the verification equation for any message, and
// every X25519 shared secret derived from it is the all-zero key: the
// order-1 and order-2 points convert to the all-zero Montgomery u
// directly, and the order-4 and order-8 points are annihilated by the
// scalar clamping instead. `is_weak` covers the whole torsion set.
// Mixed-order points are correctly NOT rejected, since clamping clears
// the cofactor component. Rejecting
// at resolution is what makes this the choke point: every consumer that
// resolves a DID through here inherits the rejection, and a recipient
// that cannot resolve already fails closed downstream.
if key.is_weak() {
return Err(Error::InvalidDid("small-order ed25519 key".to_string()));
}
Ok(key)
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::InvalidDid(e.to_string()))
}
/// Return the full DID string as a `&str`.
@@ -137,9 +105,7 @@ impl Did {
pub fn validate(&self) -> Result<()> {
match self.method() {
"key" | "web" | "gitlawb" => Ok(()),
other => Err(Error::InvalidDid(format!(
"unsupported DID method: {other}"
))),
other => Err(Error::InvalidDid(format!("unsupported DID method: {other}"))),
}
}
}
@@ -155,9 +121,7 @@ impl FromStr for Did {
fn from_str(s: &str) -> Result<Self> {
if !s.starts_with("did:") {
return Err(Error::InvalidDid(format!(
"'{s}' does not start with 'did:'"
)));
return Err(Error::InvalidDid(format!("'{s}' does not start with 'did:'")));
}
let did = Self(s.to_string());
did.validate()?;
@@ -171,7 +135,7 @@ impl From<Did> for String {
}
}
/// A DID Document for a Twigpine actor.
/// A DID Document for a gitlawb actor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDocument {
#[serde(rename = "@context")]
@@ -284,140 +248,4 @@ mod tests {
let json = serde_json::to_string_pretty(&doc).unwrap();
assert!(json.contains("Ed25519VerificationKey2020"));
}
#[test]
fn to_verifying_key_fails_for_did_web() {
let web = Did::web("example.com");
let result = web.to_verifying_key();
assert!(
result.is_err(),
"did:web cannot resolve to a verifying key locally"
);
}
#[test]
fn to_verifying_key_fails_for_did_gitlawb() {
let gl = Did::gitlawb("z6MkSomeKey");
let result = gl.to_verifying_key();
assert!(
result.is_err(),
"did:gitlawb cannot resolve to a verifying key locally"
);
}
#[test]
fn from_str_rejects_missing_did_prefix() {
let result = "key:z6MkABCD".parse::<Did>();
assert!(result.is_err());
}
#[test]
fn from_str_rejects_unsupported_method() {
let result = "did:ethr:0x1234abcd".parse::<Did>();
assert!(result.is_err());
}
#[test]
fn validate_accepts_all_supported_methods() {
Did::web("example.com").validate().unwrap();
Did::gitlawb("z6MkSomeKey").validate().unwrap();
Keypair::generate().did().validate().unwrap();
}
#[test]
fn is_did_key_and_is_did_gitlawb_predicates() {
let key_did = Keypair::generate().did();
assert!(key_did.is_did_key());
assert!(!key_did.is_did_gitlawb());
let gl = Did::gitlawb("z6MkSomeKey");
assert!(!gl.is_did_key());
assert!(gl.is_did_gitlawb());
}
#[test]
fn method_id_extraction() {
assert_eq!(Did::web("example.com").method_id(), "example.com");
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
}
/// An oversized method-specific id is refused on its length, before the
/// base58 decode runs.
///
/// base58 decoding is quadratic in the input, and `to_verifying_key` is
/// reached from two places that take an attacker-supplied string: the
/// unauthenticated peer-announce gate, and the HTTP-signature keyid. A
/// 64k-character DID measured 8s through the router without this guard
/// against 4.6ms with it, on a tokio worker with no spawn_blocking, so a
/// single caller could buy CPU-seconds by the thousand.
///
/// Asserting the MESSAGE is what makes this load-bearing: an oversized id
/// errors either way, so a test that only checked `is_err()` would pass
/// with the guard removed, having merely paid the cost it exists to avoid.
#[test]
fn to_verifying_key_rejects_an_oversized_method_id_before_decoding() {
let oversized = format!("did:key:z{}", "z".repeat(64_000));
let did: Did = oversized.parse().expect("method is still `key`");
let start = std::time::Instant::now();
let err = did.to_verifying_key().expect_err("must be refused");
let elapsed = start.elapsed();
assert!(
err.to_string().contains("too long"),
"must be refused on length, not after the decode: {err}"
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"refusal must not pay the quadratic decode: took {elapsed:?}"
);
}
/// The bound must not reject a real key. Every `did:key` this project
/// mints is a fixed 48 characters, so the limit is slack, not a behavior
/// change.
#[test]
fn to_verifying_key_still_accepts_a_real_did_key() {
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&[7u8; 32]);
let did = Did::from_verifying_key(&signing.verifying_key());
assert_eq!(
did.method_id().len(),
48,
"did:key method-id is fixed-width"
);
did.to_verifying_key().expect("a real did:key must resolve");
}
/// A `did:key` can encode a small-order (weak) Ed25519 point. Such a key
/// satisfies the verification equation for any message and, converted to
/// Montgomery form for X25519, yields the all-zero shared secret, so an
/// envelope sealed to it is decryptable by anyone. Resolution is the choke
/// point every `Did`-based consumer shares, so it fails closed here.
#[test]
fn to_verifying_key_rejects_a_small_order_key() {
// The compressed identity point, the canonical small-order key.
let mut weak = [0u8; 32];
weak[0] = 1;
let mut prefixed = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
prefixed.extend_from_slice(ED25519_MULTICODEC);
prefixed.extend_from_slice(&weak);
let did: Did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &prefixed)
)
.parse()
.expect("a small-order did:key is still well-formed");
let err = did
.to_verifying_key()
.expect_err("a small-order did:key must not resolve");
match err {
Error::InvalidDid(msg) => assert!(
msg.contains("small-order"),
"rejection must name the small-order key, got: {msg}"
),
other => panic!("expected Error::InvalidDid, got {other:?}"),
}
}
}
-795
View File
@@ -1,795 +0,0 @@
//! Envelope encryption for withheld blobs (Option B). A random content key
//! encrypts the blob (XChaCha20-Poly1305); the content key is wrapped to each
//! recipient via an X25519 box keyed from their Ed25519 `did:key`. The node
//! seals with public keys only; readers open with their own private key.
use crate::identity::Keypair;
use anyhow::{Context, Result};
use ed25519_dalek::VerifyingKey;
use zeroize::Zeroizing;
/// X25519 public key (Montgomery u) for an Ed25519 verifying key.
fn x25519_public(vk: &VerifyingKey) -> Result<[u8; 32]> {
use curve25519_dalek::edwards::CompressedEdwardsY;
// Every X25519 shared secret derived from a small-order point is the
// all-zero key: order-1 and order-2 convert to the all-zero Montgomery u
// directly, order-4 and order-8 are annihilated by the scalar clamping in
// x25519_secret_from_seed. Either way the per-recipient wrap could be
// rebuilt by anyone. Resolution already refuses such a key (see
// Did::to_verifying_key); this guard covers the SEAL side on its own terms
// for a caller that obtained the key some other way. The open side
// (open_blob's attacker-supplied `eph`) skips any entry whose exchange with
// this reader yields the all-zero shared secret
// (`yields_all_zero_shared_secret`), regardless of whether the bytes decode
// to an Edwards point.
if vk.is_weak() {
return Err(anyhow::anyhow!("verifying key is a small-order point"));
}
let edwards = CompressedEdwardsY::from_slice(vk.as_bytes())
.ok()
.and_then(|c| c.decompress())
.context("verifying key is not a valid edwards point")?;
Ok(edwards.to_montgomery().to_bytes())
}
/// True when the X25519 exchange between an attacker-supplied `u` and this
/// reader's scalar yields the all-zero shared secret.
///
/// Asked of the RESULT, not of the encoding, and that distinction is the whole
/// guard. The previous version decompressed `u` to an Edwards point and asked
/// `is_small_order`, which reports "safe" for any low-order encoding that does
/// not decompress at all. Measured across the seven standard low-order
/// encodings, six were caught and `u = p - 1` was not: it fails to decompress,
/// so the check returned false, and the exchange is still the all-zero secret.
/// An entry built on it unwraps for every reader.
///
/// Enumerating encodings cannot be exhaustive, because the attack depends on the
/// shared secret rather than on the bytes that produced it, and twist and
/// non-canonical inputs keep arriving. Asking the result needs no list.
fn yields_all_zero_shared_secret(u: &[u8; 32], scalar: &[u8; 32]) -> bool {
use curve25519_dalek::montgomery::MontgomeryPoint;
MontgomeryPoint(*u).mul_clamped(*scalar).to_bytes() == [0u8; 32]
}
/// X25519 secret scalar for an Ed25519 seed (SHA-512 of seed, lower 32, clamped).
/// Returns the scalar wrapped in `Zeroizing`, and scrubs the intermediate
/// SHA-512 digest, so no copy of this secret material lingers in freed memory.
fn x25519_secret_from_seed(seed: &[u8; 32]) -> Zeroizing<[u8; 32]> {
use sha2::{Digest, Sha512};
use zeroize::Zeroize;
let mut h = Sha512::digest(seed);
let mut s = Zeroizing::new([0u8; 32]);
s.copy_from_slice(&h[..32]);
s[0] &= 248;
s[31] &= 127;
s[31] |= 64;
h.as_mut_slice().zeroize();
s
}
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use crypto_box::{
aead::{AeadCore, OsRng},
ChaChaBox, PublicKey as XPublic, SecretKey as XSecret,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
const MAGIC: &[u8] = b"GLENC";
const VERSION: u8 = 2;
/// Upper bound on the blinded recipient list. The envelope author controls
/// this count and `open_blob` pays one X25519 exchange per entry before it can
/// answer "not a recipient", so the count has to be capped rather than
/// optimized (#419). Legitimate envelopes carry a handful of authorized
/// readers, so 256 is far above real use.
const MAX_RECIPIENTS: usize = 256;
#[derive(Serialize, Deserialize)]
struct Recipient {
eph: String, // base64 ephemeral x25519 pubkey (32B)
nonce: String, // base64 box nonce (24B)
wrap: String, // base64 wrapped content key
}
#[derive(Serialize, Deserialize)]
struct Header {
alg: String,
nonce: String, // base64 body nonce (24B)
recipients: Vec<Recipient>,
}
/// Encrypt `plaintext` so any of `recipients` (Ed25519 keys) can decrypt.
pub fn seal_blob(plaintext: &[u8], recipients: &[VerifyingKey]) -> Result<Vec<u8>> {
if recipients.is_empty() {
return Err(anyhow::anyhow!("seal_blob: no recipients"));
}
// Same bound open_blob enforces: an envelope over the cap could never be
// opened, so refuse to seal it (#419).
if recipients.len() > MAX_RECIPIENTS {
return Err(anyhow::anyhow!(
"seal_blob: {} recipients, over the {MAX_RECIPIENTS} cap",
recipients.len()
));
}
let mut content_key = [0u8; 32];
OsRng.fill_bytes(&mut content_key);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
let mut body_nonce = [0u8; 24];
OsRng.fill_bytes(&mut body_nonce);
let body = body_cipher
.encrypt(XNonce::from_slice(&body_nonce), plaintext)
.map_err(|e| anyhow::anyhow!("body encrypt: {e}"))?;
let mut wrapped = Vec::with_capacity(recipients.len());
for vk in recipients {
let recip_x = XPublic::from(x25519_public(vk)?);
let eph = XSecret::generate(&mut OsRng);
let abox = ChaChaBox::new(&recip_x, &eph);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let ct = abox
.encrypt(&nonce, &content_key[..])
.map_err(|e| anyhow::anyhow!("wrap: {e}"))?;
wrapped.push(Recipient {
eph: B64.encode(eph.public_key().as_bytes()),
nonce: B64.encode(nonce),
wrap: B64.encode(ct),
});
}
let header = Header {
alg: "xchacha20poly1305".into(),
nonce: B64.encode(body_nonce),
recipients: wrapped,
};
let header_json = serde_json::to_vec(&header).context("encode header")?;
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
out.extend_from_slice(&header_json);
out.extend_from_slice(&body);
Ok(out)
}
/// Decrypt an envelope with `keypair`. Errors if not a recipient or on auth fail.
pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result<Vec<u8>> {
let mut p = 0;
if envelope.len() < MAGIC.len() + 1 + 4 || &envelope[..MAGIC.len()] != MAGIC {
return Err(anyhow::anyhow!("bad envelope magic"));
}
p += MAGIC.len();
if envelope[p] != VERSION {
return Err(anyhow::anyhow!("unsupported envelope version"));
}
p += 1;
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header: Header =
serde_json::from_slice(envelope.get(p..p + hlen).context("truncated header")?)
.context("decode header")?;
// Reject before the recipient scan: every entry below is an X25519
// exchange, and a non-recipient would run the whole list (#419).
if header.recipients.len() > MAX_RECIPIENTS {
return Err(anyhow::anyhow!(
"envelope carries {} recipients, over the {MAX_RECIPIENTS} cap",
header.recipients.len()
));
}
let body = &envelope[p + hlen..];
// The raw scalar is kept alongside the box secret so the exchange can be
// tested BEFORE a box is built from an attacker-supplied ephemeral.
let my_x_scalar = x25519_secret_from_seed(&keypair.to_seed());
let my_x = XSecret::from(*my_x_scalar);
// Identities are blinded: no entry says which recipient it belongs to, so
// try each one. The ChaChaBox AEAD tag authenticates, so exactly the
// reader's own entry unwraps; every other entry fails cleanly.
let mut content_key: Option<Vec<u8>> = None;
for entry in &header.recipients {
let eph = match B64
.decode(&entry.eph)
.ok()
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
{
// An ephemeral whose exchange with this reader is the all-zero
// shared secret would unwrap for anyone, so the entry is skipped.
// Tested on the exchange itself: an encoding-shaped check misses
// every low-order input that does not decompress to Edwards.
Some(b) if !yields_all_zero_shared_secret(&b, &my_x_scalar) => XPublic::from(b),
Some(_) => continue,
None => continue,
};
// from_slice panics on a wrong length, and the envelope is attacker
// controlled, so validate the 24-byte box nonce before using it.
let nonce = match B64
.decode(&entry.nonce)
.ok()
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
{
Some(n) => n,
None => continue,
};
let wrap = match B64.decode(&entry.wrap) {
Ok(w) => w,
Err(_) => continue,
};
let abox = ChaChaBox::new(&eph, &my_x);
if let Ok(ck) = abox.decrypt(
crypto_box::aead::generic_array::GenericArray::from_slice(&nonce),
wrap.as_slice(),
) {
content_key = Some(ck);
break;
}
}
let content_key = content_key.context("not a recipient of this envelope")?;
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key)
.map_err(|e| anyhow::anyhow!("content key: {e}"))?;
let body_nonce = B64
.decode(&header.nonce)
.ok()
.and_then(|n| <[u8; 24]>::try_from(n.as_slice()).ok())
.context("invalid body nonce")?;
body_cipher
.decrypt(XNonce::from_slice(&body_nonce), body)
.map_err(|_| anyhow::anyhow!("body decrypt failed"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Keypair;
#[test]
fn ed25519_to_x25519_keypair_agrees() {
// The X25519 public derived from the Ed25519 public must equal the
// X25519 public of the X25519 secret derived from the same seed.
let kp = Keypair::generate();
let seed = kp.to_seed();
let xpub_from_public = x25519_public(&kp.verifying_key()).unwrap();
let xsec = x25519_secret_from_seed(&seed);
let xpub_from_secret = crypto_box::SecretKey::from(*xsec).public_key().to_bytes();
assert_eq!(xpub_from_public, xpub_from_secret);
}
#[test]
fn seal_open_round_trip_for_recipients() {
let owner = Keypair::generate();
let reader_a = Keypair::generate();
let reader_b = Keypair::generate();
let msg = b"private blob contents";
let env = seal_blob(msg, &[owner.verifying_key(), reader_a.verifying_key()]).unwrap();
assert_eq!(open_blob(&env, &owner).unwrap(), msg);
assert_eq!(open_blob(&env, &reader_a).unwrap(), msg);
assert!(
open_blob(&env, &reader_b).is_err(),
"non-recipient must fail"
);
}
#[test]
fn tampered_envelope_fails() {
let owner = Keypair::generate();
let mut env = seal_blob(b"hi", &[owner.verifying_key()]).unwrap();
let last = env.len() - 1;
env[last] ^= 0x01;
assert!(open_blob(&env, &owner).is_err());
}
#[test]
fn v2_header_contains_no_recipient_pubkey() {
// The blinded envelope header must not carry any recipient's public key.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Slice out the header bytes using the envelope framing:
// MAGIC | version(1B) | header_len(4B LE) | header_json | body
let mut p = MAGIC.len() + 1; // skip MAGIC + version byte
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header = &env[p..p + hlen];
let header_str = String::from_utf8_lossy(header);
let pubkey_b64 = B64.encode(reader.verifying_key().as_bytes());
assert!(
!header_str.contains(&pubkey_b64),
"recipient public key must not appear in the blinded header"
);
}
#[test]
fn v1_envelope_is_rejected() {
let reader = Keypair::generate();
let mut env = seal_blob(b"hi", &[reader.verifying_key()]).unwrap();
// Flip the version byte (immediately after MAGIC) from 2 to 1.
env[MAGIC.len()] = 1;
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("unsupported envelope version"),
"expected version-rejection error, got: {err}"
);
}
#[test]
fn malformed_nonce_returns_err_not_panic() {
// from_slice panics on wrong-length input; a crafted envelope on the
// public recovery path must surface an error, never panic.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Split the envelope framing into header JSON and body.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
let reframe = |header: &serde_json::Value| -> Vec<u8> {
let hj = serde_json::to_vec(header).unwrap();
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(hj.len() as u32).to_le_bytes());
out.extend_from_slice(&hj);
out.extend_from_slice(body);
out
};
let bad_nonce = serde_json::Value::String(B64.encode([0u8; 12]));
// Corrupted per-recipient nonce: entry is skipped, no match.
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
header["recipients"][0]["nonce"] = bad_nonce.clone();
assert!(open_blob(&reframe(&header), &reader).is_err());
// Corrupted body nonce: unwrap succeeds, body nonce is rejected.
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
header["nonce"] = bad_nonce;
assert!(open_blob(&reframe(&header), &reader).is_err());
}
#[test]
fn over_cap_recipient_list_is_rejected_before_the_scan() {
// #419: the envelope author controls the recipient count and each entry
// costs an X25519 exchange, so the list is capped. Reframe a valid
// envelope with MAX_RECIPIENTS + 1 junk entries.
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
let junk = serde_json::json!({
"eph": B64.encode([0u8; 32]),
"nonce": B64.encode([0u8; 24]),
"wrap": B64.encode([0u8; 48]),
});
header["recipients"] = serde_json::Value::Array(vec![junk.clone(); MAX_RECIPIENTS + 1]);
let hj = serde_json::to_vec(&header).unwrap();
let mut fat = Vec::new();
fat.extend_from_slice(MAGIC);
fat.push(VERSION);
fat.extend_from_slice(&(hj.len() as u32).to_le_bytes());
fat.extend_from_slice(&hj);
fat.extend_from_slice(body);
let err = open_blob(&fat, &reader).unwrap_err();
assert!(
err.to_string().contains("recipients"),
"expected the recipient-cap error, got: {err}"
);
// Exactly at the cap the envelope is still processed end to end.
header["recipients"] = serde_json::Value::Array(vec![junk; MAX_RECIPIENTS]);
let hj = serde_json::to_vec(&header).unwrap();
let mut at_cap = Vec::new();
at_cap.extend_from_slice(MAGIC);
at_cap.push(VERSION);
at_cap.extend_from_slice(&(hj.len() as u32).to_le_bytes());
at_cap.extend_from_slice(&hj);
at_cap.extend_from_slice(body);
let err = open_blob(&at_cap, &reader).unwrap_err();
assert!(
!err.to_string().contains("over the"),
"at-cap envelope must reach the scan, got: {err}"
);
}
#[test]
fn seal_refuses_a_recipient_list_over_the_cap() {
// An envelope over the cap can never be opened, so seal must reject it
// instead of minting one.
let keys: Vec<VerifyingKey> = (0..MAX_RECIPIENTS + 1)
.map(|_| Keypair::generate().verifying_key())
.collect();
let err = seal_blob(b"blob", &keys).unwrap_err();
assert!(
err.to_string().contains("recipients"),
"expected the recipient-cap error, got: {err}"
);
}
/// Every standard low-order X25519 encoding must be detected by
/// `yields_all_zero_shared_secret`, including encodings that do not
/// decompress to an Edwards point (notably `u = p - 1`). The open side
/// rejects on the exchange result, not on decode shape, because an
/// encoding-shaped check misses non-decompressing low-order inputs while
/// still yielding the all-zero shared secret.
#[test]
fn all_zero_shared_secret_is_detected_for_every_standard_low_order_encoding() {
// The seven standard X25519 low-order encodings, driven as a set rather
// than as the two that happen to decompress. The encoding-shaped check
// this replaced reported `p - 1` as safe, and its exchange is all-zero.
let mut one = [0u8; 32];
one[0] = 1;
let hex = |h: &str| -> [u8; 32] {
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).unwrap();
}
out
};
let vectors = [
("u=0", [0u8; 32]),
("u=1", one),
(
"order8-a",
hex("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
),
(
"order8-b",
hex("5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
),
(
"p-1",
hex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
(
"p",
hex("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
(
"p+1",
hex("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
),
];
let kp = Keypair::generate();
let scalar = x25519_secret_from_seed(&kp.to_seed());
for (name, u) in vectors {
assert!(
yields_all_zero_shared_secret(&u, &scalar),
"{name} is a low-order encoding and must be detected"
);
}
// And the other direction, or a guard that returned true always would
// pass everything above while making the envelope unopenable.
let real_u = x25519_public(&kp.verifying_key()).unwrap();
assert!(
!yields_all_zero_shared_secret(&real_u, &scalar),
"a real key must not be treated as low-order"
);
}
/// The open side must skip an entry whose header-supplied `eph` is a
/// low-order point: such an entry forces the all-zero shared secret and
/// would unwrap for any reader. A poisoned entry among honest ones must
/// not DoS the envelope — the honest entry still opens.
#[test]
fn open_blob_skips_a_low_order_ephemeral_and_still_opens_honest_entry() {
use curve25519_dalek::montgomery::MontgomeryPoint;
let reader = Keypair::generate();
let env = seal_blob(b"private blob contents", &[reader.verifying_key()]).unwrap();
// Split the envelope framing into header JSON and body.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header_bytes = &env[p..p + hlen];
let body = &env[p + hlen..];
// A low-order Montgomery u (u = 0) encoded as the entry's eph.
let low_order_u = MontgomeryPoint([0u8; 32]).to_bytes();
let poisoned = serde_json::json!({
"eph": B64.encode(low_order_u),
"nonce": B64.encode([0u8; 24]),
"wrap": B64.encode([0u8; 32]),
});
let reframe = |header: &serde_json::Value| -> Vec<u8> {
let hj = serde_json::to_vec(header).unwrap();
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.extend_from_slice(&(hj.len() as u32).to_le_bytes());
out.extend_from_slice(&hj);
out.extend_from_slice(body);
out
};
let mut header: serde_json::Value = serde_json::from_slice(header_bytes).unwrap();
// Prepend the poisoned entry. The reader's honest entry must still
// unwrap, so the envelope opens despite the low-order entry.
let recipients = header["recipients"].as_array_mut().unwrap();
recipients.insert(0, poisoned);
assert_eq!(
open_blob(&reframe(&header), &reader).unwrap(),
b"private blob contents",
"the honest entry must still open despite a low-order eph entry"
);
}
/// Must-not for the open-side guard: an envelope whose ONLY entry is built
/// on a low-order ephemeral must not open for any reader. Without the
/// guard the all-zero shared secret unwraps for everyone, so the attacker
/// controls the plaintext and the envelope's confidentiality is void.
#[test]
fn open_blob_rejects_a_poisoned_only_envelope() {
use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305, XNonce};
use crypto_box::aead::AeadCore;
use curve25519_dalek::montgomery::MontgomeryPoint;
// Build a poisoned envelope from scratch: one recipient entry whose
// eph is the low-order u = 0. The wrap is the encryption of a content
// key under the all-zero shared secret, so without the guard any
// reader's ChaChaBox against u = 0 decrypts it.
let mut content_key: [u8; 32] = Default::default();
OsRng.fill_bytes(&mut content_key);
let attacker_x = XSecret::generate(&mut OsRng);
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let wrap = zero_box.encrypt(&nonce, &content_key[..]).unwrap();
let mut body_nonce: [u8; 24] = Default::default();
OsRng.fill_bytes(&mut body_nonce);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let body = body_cipher
.encrypt(
XNonce::from_slice(&body_nonce),
b"attacker-chosen plaintext".as_slice(),
)
.unwrap();
let header = serde_json::json!({
"alg": "xchacha20poly1305",
"nonce": B64.encode(body_nonce),
"recipients": [{
"eph": B64.encode(MontgomeryPoint([0u8; 32]).to_bytes()),
"nonce": B64.encode(nonce),
"wrap": B64.encode(wrap),
}],
});
let header_json = serde_json::to_vec(&header).unwrap();
let mut env = Vec::new();
env.extend_from_slice(MAGIC);
env.push(VERSION);
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
env.extend_from_slice(&header_json);
env.extend_from_slice(&body);
let reader = Keypair::generate();
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("not a recipient"),
"a poisoned-only envelope must not open for anyone, got: {err}"
);
}
/// The same must-not, driven with the low-order encoding that does NOT map
/// to an Edwards point.
///
/// This is the case an encoding-shaped guard cannot see. `u = p - 1` is a
/// legal X25519 input whose exchange is the all-zero secret for any scalar,
/// and `MontgomeryPoint::to_edwards` returns None for it, so a check written
/// as "decompress, then ask is_small_order" reports it as safe. Measured
/// across the seven standard low-order encodings: six are caught that way
/// and this one is not.
///
/// Found by a cross-family review of the head that first added the guard,
/// and the comment beside that guard asserted this case was already handled
/// by a decode path that does not exist. That is why the fix rejects on the
/// RESULT of the exchange rather than on a list of encodings: the result is
/// what the attack actually depends on, and it needs no enumeration to be
/// exhaustive.
#[test]
fn open_blob_rejects_a_non_edwards_low_order_ephemeral() {
// No blanket `use ...::Aead` here. Two aead versions are reachable, and a
// blanket import silently resolved the wrap through the one open_blob does
// NOT decrypt with, producing a ciphertext that could never open. The test
// then passed with the guard, without it, and with the original pre-fix
// guard alike, proving nothing. Every call below names its trait.
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
use crypto_box::aead::AeadCore;
// u = p - 1 = 2^255 - 20, little endian.
let mut eph = [0xffu8; 32];
eph[0] = 0xec;
eph[31] = 0x7f;
let mut content_key: [u8; 32] = Default::default();
OsRng.fill_bytes(&mut content_key);
let attacker_x = XSecret::generate(&mut OsRng);
let zero_box = ChaChaBox::new(&XPublic::from(<[u8; 32]>::default()), &attacker_x);
let nonce = ChaChaBox::generate_nonce(&mut OsRng);
let wrap = crypto_box::aead::Aead::encrypt(&zero_box, &nonce, &content_key[..]).unwrap();
let mut body_nonce: [u8; 24] = Default::default();
OsRng.fill_bytes(&mut body_nonce);
let body_cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let body = chacha20poly1305::aead::Aead::encrypt(
&body_cipher,
XNonce::from_slice(&body_nonce),
b"attacker-chosen plaintext".as_slice(),
)
.unwrap();
let header = serde_json::json!({
"alg": "xchacha20poly1305",
"nonce": B64.encode(body_nonce),
"recipients": [{
"eph": B64.encode(eph),
"nonce": B64.encode(nonce),
"wrap": B64.encode(wrap),
}],
});
let header_json = serde_json::to_vec(&header).unwrap();
let mut env = Vec::new();
env.extend_from_slice(MAGIC);
env.push(VERSION);
env.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
env.extend_from_slice(&header_json);
env.extend_from_slice(&body);
let reader = Keypair::generate();
let err = open_blob(&env, &reader).unwrap_err();
assert!(
err.to_string().contains("not a recipient"),
"an envelope whose only entry uses a non-Edwards low-order eph must not \
open for anyone, got: {err}"
);
}
/// The compressed identity point: a well-formed Ed25519 encoding that is
/// small-order, so every shared secret derived from it is all-zero.
fn weak_verifying_key() -> VerifyingKey {
let mut weak = [0u8; 32];
weak[0] = 1;
let vk = VerifyingKey::from_bytes(&weak).expect("identity point decompresses");
assert!(vk.is_weak(), "fixture precondition: key is small-order");
vk
}
/// A small-order recipient key converts to Montgomery u = 0, and X25519
/// against u = 0 is the all-zero shared secret for ANY scalar, so the
/// wrapping box is reconstructable with no secret at all. Reject at the
/// primitive so the seal side is safe regardless of how the caller
/// obtained the key.
#[test]
fn x25519_public_rejects_a_small_order_key() {
let weak_vk = weak_verifying_key();
assert!(
x25519_public(&weak_vk).is_err(),
"a small-order key must not yield an x25519 public key"
);
}
/// Control: a legitimate key still converts, and to a non-zero u.
#[test]
fn x25519_public_still_accepts_a_real_key() {
let kp = Keypair::generate();
let u = x25519_public(&kp.verifying_key()).expect("a real key must convert");
assert_ne!(u, [0u8; 32], "a real key must not map to the zero point");
}
/// The guard has to propagate: no envelope may be produced for a weak
/// recipient even when the DID choke point is bypassed entirely.
#[test]
fn seal_blob_refuses_a_small_order_recipient() {
let weak_vk = weak_verifying_key();
assert!(
seal_blob(b"withheld", &[weak_vk]).is_err(),
"sealing to a weak recipient must fail"
);
// And a mixed set must fail too: one weak recipient exposes the shared
// content key, so a partial envelope is not an acceptable outcome.
let honest = Keypair::generate();
assert!(
seal_blob(b"withheld", &[honest.verifying_key(), weak_vk]).is_err(),
"a mixed honest+weak recipient set must fail closed, not seal partially"
);
}
/// Control for the two above: the ordinary seal/open round trip is intact.
#[test]
fn legit_seal_open_round_trip_still_works() {
let reader = Keypair::generate();
let env = seal_blob(b"withheld blob", &[reader.verifying_key()]).expect("seal");
assert_eq!(open_blob(&env, &reader).expect("open"), b"withheld blob");
}
/// The defect this fix exists to close, kept as an executable regression
/// rather than an assertion about absence. It runs the real attack: craft a
/// small-order did:key, get it into a recipient set alongside an honest
/// reader, then rebuild the wrapping box from the all-zero shared secret
/// that a small-order recipient forces. If any guard regresses, this does
/// not merely fail, it fails printing the plaintext it recovered.
#[test]
fn attacker_cannot_recover_plaintext_via_a_weak_recipient() {
use crate::did::Did;
use std::str::FromStr;
let weak_vk = weak_verifying_key();
// Layer 1: the attacker's did:key string must not resolve at all.
let weak_did = Did::from_verifying_key(&weak_vk).to_string();
assert!(
Did::from_str(&weak_did)
.expect("still a well-formed did:key")
.to_verifying_key()
.is_err(),
"a small-order did:key must not resolve"
);
// Layer 2: even handed the key directly, sealing must refuse. One weak
// recipient would expose the single shared content key, so the honest
// reader's blob would be readable by anyone.
let honest = Keypair::generate();
let secret_plaintext = b"WITHHELD BLOB CONTENTS";
let envelope = match seal_blob(secret_plaintext, &[honest.verifying_key(), weak_vk]) {
Err(_) => return, // no envelope exists, nothing to attack
Ok(env) => env,
};
// Only reachable if a guard regressed. Run the attack and report what
// it got, so the failure names the actual exposure.
let mut p = MAGIC.len() + 1;
let hlen = u32::from_le_bytes(envelope[p..p + 4].try_into().unwrap()) as usize;
p += 4;
let header: serde_json::Value = serde_json::from_slice(&envelope[p..p + hlen]).unwrap();
let body = &envelope[p + hlen..];
let body_nonce = B64.decode(header["nonce"].as_str().unwrap()).unwrap();
// X25519 against u = 0 is the all-zero shared secret for any scalar, so
// the sealer's box is reconstructable with a key the attacker picks.
let zero_box = ChaChaBox::new(
&XPublic::from(<[u8; 32]>::default()),
&XSecret::generate(&mut OsRng),
);
for r in header["recipients"].as_array().unwrap() {
let n = B64.decode(r["nonce"].as_str().unwrap()).unwrap();
let w = B64.decode(r["wrap"].as_str().unwrap()).unwrap();
if let Ok(content_key) = zero_box.decrypt(n.as_slice().into(), w.as_slice()) {
let cipher = XChaCha20Poly1305::new_from_slice(&content_key).unwrap();
let recovered = cipher
.decrypt(XNonce::from_slice(&body_nonce), body)
.expect("body decrypts once the content key is out");
panic!(
"REGRESSION: attacker recovered plaintext with no private key: {:?}",
String::from_utf8_lossy(&recovered)
);
}
}
panic!("an envelope was sealed to a small-order recipient; the seal guard regressed");
}
}
+38 -270
View File
@@ -1,6 +1,6 @@
//! HTTP Signatures (RFC 9421) for gitlawb.
//!
//! Every write request to a Twigpine node must be signed by the actor's
//! Every write request to a gitlawb node must be signed by the actor's
//! Ed25519 private key. The signature covers:
//! - `@method` — HTTP method (uppercase)
//! - `@path` — request path and query
@@ -11,16 +11,16 @@
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>
//! Signature: sig1=:base64signature:
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{Engine, engine::general_purpose::STANDARD};
use chrono::Utc;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use sha2::{Sha256, Digest};
use std::collections::HashMap;
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// The component identifiers covered by every Twigpine signature.
/// The component identifiers covered by every gitlawb signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
/// The three headers produced by RFC 9421 signing.
@@ -53,34 +53,25 @@ impl HttpSignature {
let sig_input = sig_input.trim();
// Expect: sig1=("@method" "@path" "content-digest");keyid="...";alg="...";created=...
let rest = sig_input.strip_prefix("sig1=").ok_or_else(|| {
Error::HttpSignature("Signature-Input must start with 'sig1='".into())
})?;
let rest = sig_input
.strip_prefix("sig1=")
.ok_or_else(|| Error::HttpSignature("Signature-Input must start with 'sig1='".into()))?;
let open = rest
.find('(')
.ok_or_else(|| Error::HttpSignature("missing '(' in Signature-Input".into()))?;
let close = rest
.find(')')
.ok_or_else(|| Error::HttpSignature("missing ')' in Signature-Input".into()))?;
let open = rest.find('(').ok_or_else(|| {
Error::HttpSignature("missing '(' in Signature-Input".into())
})?;
let close = rest.find(')').ok_or_else(|| {
Error::HttpSignature("missing ')' in Signature-Input".into())
})?;
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
// RFC 9421 §2.1: a component identifier appears at most once. Gitlawb
// only supports bare names; parameters are rejected so reordered
// equivalent forms cannot bypass duplicate detection as distinct strings.
let mut components = Vec::new();
let mut seen = HashSet::new();
for token in components_str.split_whitespace() {
let name = parse_covered_component(token)?;
if !seen.insert(name.clone()) {
return Err(Error::HttpSignature(format!(
"duplicate covered component '{name}' in Signature-Input"
)));
}
components.push(name);
}
// "\"@method\" \"@path\" \"content-digest\"" → ["@method", "@path", "content-digest"]
let components: Vec<String> = components_str
.split_whitespace()
.map(|s| s.trim_matches('"').to_string())
.collect();
let params = parse_params(params_str)?;
@@ -107,63 +98,26 @@ impl HttpSignature {
.trim()
.strip_prefix("sig1=:")
.and_then(|s| s.strip_suffix(':'))
.ok_or_else(|| Error::HttpSignature("Signature must be 'sig1=:base64:'".into()))?;
.ok_or_else(|| {
Error::HttpSignature("Signature must be 'sig1=:base64:'".into())
})?;
let signature_bytes = STANDARD
.decode(sig_b64)
.map_err(|e| Error::HttpSignature(format!("invalid base64 in Signature: {e}")))?;
Ok(Self {
key_id,
alg,
created,
components,
signature_bytes,
})
Ok(Self { key_id, alg, created, components, signature_bytes })
}
/// Reject a `created` timestamp outside the acceptance window, which is
/// ASYMMETRIC: up to 5 minutes into the past, but only 60 seconds into the
/// future.
///
/// The two directions are two comparisons rather than one distance, because
/// they are not the same event. A signature that arrives late is ordinary:
/// clocks run slow, requests queue, and networks are not instant, so the
/// past side keeps the full 300-second allowance. A signature stamped ahead
/// of this node's clock is not ordinary, and folding both sides into an
/// `abs()` let a signer extend its own validity: signed at T with
/// `created = T + 300`, it stayed acceptable until roughly T + 600, so the
/// effective window was double the one this function advertises. The
/// forward tolerance exists only to absorb honest drift between two hosts,
/// which is what 60 seconds buys; it is the same allowance the gossip
/// path's freshness check uses for the same reason.
/// Reject if the `created` timestamp is more than 5 minutes from now.
pub fn check_created(&self) -> Result<()> {
const MAX_AGE_SECS: i64 = 300;
const MAX_FUTURE_SECS: i64 = 60;
let now = Utc::now().timestamp();
// Saturating, not plain subtraction. `created` is parsed as an
// unrestricted i64 straight from the Signature-Input header, so a
// sender picks it: at i64::MIN the past-side subtraction overflows,
// which panics a debug build and wraps a release one into an absurd
// value that can read as acceptable. Saturating gives the answer the
// check wants at both extremes, since a timestamp that far away is
// refused by whichever side it saturates toward.
let age = now.saturating_sub(self.created);
if age > MAX_AGE_SECS {
let skew = (now - self.created).abs();
if skew > 300 {
return Err(Error::HttpSignature(format!(
"clock skew too large: created {age}s in the past (max {MAX_AGE_SECS}s)"
"clock skew too large: {skew}s (max 300s)"
)));
}
let ahead = self.created.saturating_sub(now);
if ahead > MAX_FUTURE_SECS {
return Err(Error::HttpSignature(format!(
"clock skew too large: created {ahead}s in the future (max {MAX_FUTURE_SECS}s)"
)));
}
Ok(())
}
@@ -171,7 +125,7 @@ impl HttpSignature {
pub fn missing_components(&self) -> Vec<&str> {
COVERED_COMPONENTS
.iter()
.filter(|c| !self.components.iter().any(|s| s.as_str() == **c))
.filter(|c| !self.components.contains(&c.to_string()))
.copied()
.collect()
}
@@ -224,9 +178,12 @@ pub fn sign_request(
request_values.insert("@path".to_string(), path_and_query.to_string());
request_values.insert("content-digest".to_string(), content_digest.clone());
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values)
.expect("required components always present when building");
let signing_string = build_signing_string(
COVERED_COMPONENTS,
sig_params_value,
&request_values,
)
.expect("required components always present when building");
let sig_bytes = keypair.sign(signing_string.as_bytes());
let sig_b64 = STANDARD.encode(sig_bytes.to_bytes());
@@ -245,33 +202,6 @@ pub fn compute_content_digest(body: &[u8]) -> String {
format!("sha-256=:{}:", STANDARD.encode(hasher.finalize()))
}
/// Parse one covered-component token from Signature-Input's parenthesized list.
///
/// Gitlawb only supports bare component names (`"@method"`, `"@path"`,
/// `"content-digest"`). RFC 9421 permits parameters on identifiers; they are
/// rejected rather than accepted in a form `missing_components()` and
/// `build_signing_string()` cannot handle consistently.
fn parse_covered_component(token: &str) -> Result<String> {
let token = token.trim();
let open = token
.strip_prefix('"')
.ok_or_else(|| Error::HttpSignature("component must be a quoted string".into()))?;
let close = open
.find('"')
.ok_or_else(|| Error::HttpSignature("unterminated component string".into()))?;
let name = &open[..close];
if name.is_empty() {
return Err(Error::HttpSignature("empty component name".into()));
}
let trailing = open[close + 1..].trim();
if !trailing.is_empty() {
return Err(Error::HttpSignature(format!(
"component parameters are not supported on '{name}'"
)));
}
Ok(name.to_string())
}
/// Parse `;key="value";key2=value` parameter string into a map.
fn parse_params(s: &str) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
@@ -341,91 +271,6 @@ mod tests {
assert!(missing.contains(&"content-digest"));
}
/// RFC 9421 §2.1: a component identifier must not appear twice in the
/// covered list. Repeating one is not a way to say anything, but it does
/// repeat a line in the signing string, so the size of what a verifier
/// builds (and what a node persists alongside a claim) is set by how many
/// times the caller chose to write the same name.
#[test]
fn parse_rejects_component_parameters() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest";sf);keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
)
.expect_err("parameterized components must be refused");
assert!(
err.to_string().contains("component parameters"),
"got: {err}"
);
}
#[test]
fn parse_rejects_reordered_equivalent_component_parameters() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("content-digest";sf;tr "content-digest";tr;sf "@method" "@path");keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
)
.expect_err("reordered equivalent parameters must not bypass as distinct components");
assert!(
err.to_string().contains("component parameters"),
"got: {err}"
);
}
#[test]
fn parse_many_distinct_components_completes_quickly() {
use std::time::{Duration, Instant};
let kp = Keypair::generate();
let did = kp.did();
let names: Vec<String> = (0..20_000).map(|i| format!("\"c{i}\"")).collect();
let inner = names.join(" ");
let sig_input = format!(r#"sig1=({inner});keyid="{did}";alg="ed25519";created=1000"#);
let start = Instant::now();
let result = HttpSignature::parse(
&sig_input,
"sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:",
);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"duplicate tracking must stay linear, took {elapsed:?}"
);
assert!(result.is_ok(), "distinct names should parse: {:?}", result);
}
#[test]
fn parse_rejects_duplicate_components() {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
);
let err = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
.expect_err("a repeated component must be refused");
assert!(
err.to_string().contains("duplicate"),
"the error must name the duplication, got: {err}"
);
// The control: the same list without the repeat still parses.
let ok = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1000"#
);
HttpSignature::parse(&ok, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:")
.expect("a distinct component list must still parse");
}
#[test]
fn verify_signature_end_to_end() {
use crate::identity::verify;
@@ -445,16 +290,10 @@ mod tests {
request_values.insert("content-digest".to_string(), headers.content_digest.clone());
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string =
build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let signing_string = build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let vk = sig.key_id.to_verifying_key().unwrap();
let sig_b64 = headers
.signature
.strip_prefix("sig1=:")
.unwrap()
.strip_suffix(':')
.unwrap();
let sig_b64 = headers.signature.strip_prefix("sig1=:").unwrap().strip_suffix(':').unwrap();
let sig_bytes: [u8; 64] = STANDARD.decode(sig_b64).unwrap().try_into().unwrap();
assert!(verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok());
@@ -482,82 +321,11 @@ mod tests {
let kp = Keypair::generate();
let did = kp.did();
// created=1 is way in the past — should fail clock skew check
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#
);
let sig_input = format!(r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#);
let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap();
assert!(sig.check_created().is_err());
}
/// Build a parseable signature whose `created` is `offset` seconds from now
/// (negative for the past), so a test can pin one side of the window.
fn sig_created_at_offset(offset: i64) -> HttpSignature {
let kp = Keypair::generate();
let did = kp.did();
let created = Utc::now().timestamp() + offset;
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
}
/// Build a signature at an ABSOLUTE `created`, so a test can drive the
/// extremes an offset from now cannot reach.
fn sig_created_absolute(created: i64) -> HttpSignature {
let kp = Keypair::generate();
let did = kp.did();
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap()
}
/// `created` is parsed as an unrestricted i64 from a header the sender
/// writes, so the window check has to survive both ends of the type. Before
/// the saturating form this panicked with "attempt to subtract with
/// overflow" on the past side, which is a debug-build crash any client
/// could trigger by sending one header value.
#[test]
fn extreme_created_values_are_refused_rather_than_overflowing() {
for created in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
let err = sig_created_absolute(created)
.check_created()
.expect_err("an extreme created must be refused, not accepted");
let msg = err.to_string();
assert!(
msg.contains("past") || msg.contains("future"),
"created={created} must be refused by a named direction, got: {msg}"
);
}
}
#[test]
fn created_301s_in_past_rejected() {
let err = sig_created_at_offset(-301).check_created().unwrap_err();
assert!(err.to_string().contains("past"), "{err}");
}
#[test]
fn created_299s_in_past_accepted() {
assert!(sig_created_at_offset(-299).check_created().is_ok());
}
#[test]
fn created_61s_in_future_rejected() {
let err = sig_created_at_offset(61).check_created().unwrap_err();
assert!(err.to_string().contains("future"), "{err}");
}
#[test]
fn created_30s_in_future_accepted() {
assert!(sig_created_at_offset(30).check_created().is_ok());
}
#[test]
fn created_exactly_now_accepted() {
assert!(sig_created_at_offset(0).check_created().is_ok());
}
#[test]
fn fresh_signature_passes_clock_skew() {
let kp = Keypair::generate();
+19 -117
View File
@@ -1,17 +1,17 @@
//! Keypair generation, storage, and signing.
//!
//! A Twigpine identity is an Ed25519 keypair. The public key encodes into a
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use ed25519_dalek::{SigningKey, VerifyingKey, Signer, Signature};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::did::Did;
use crate::{Error, Result};
use crate::did::Did;
/// An Ed25519 keypair that is the root identity for a Twigpine actor.
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
#[derive(Clone)]
pub struct Keypair {
signing_key: SigningKey,
@@ -47,14 +47,12 @@ impl Keypair {
/// Sign and return base64url-encoded signature string.
pub fn sign_b64(&self, msg: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let sig = self.sign(msg);
URL_SAFE_NO_PAD.encode(sig.to_bytes())
}
/// The raw 32-byte Ed25519 seed, wrapped in `Zeroizing` so the copy is
/// scrubbed on drop. This is the only seed accessor; it is used to derive
/// the X25519 secret for envelope decryption (see `crate::encrypt`).
/// Export the signing key as raw 32-byte seed (wrapped in Zeroizing).
pub fn to_seed(&self) -> Zeroizing<[u8; 32]> {
Zeroizing::new(self.signing_key.to_bytes())
}
@@ -71,16 +69,22 @@ impl Keypair {
/// Load from PEM-encoded PKCS#8 private key string.
pub fn from_pem(pem: &str) -> Result<Self> {
use pkcs8::DecodePrivateKey;
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|e| Error::Key(e.to_string()))?;
let signing_key = SigningKey::from_pkcs8_pem(pem)
.map_err(|e| Error::Key(e.to_string()))?;
Ok(Self { signing_key })
}
}
/// Verify an Ed25519 signature.
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
pub fn verify(
verifying_key: &VerifyingKey,
msg: &[u8],
sig_bytes: &[u8; 64],
) -> Result<()> {
use ed25519_dalek::Verifier;
let sig = Signature::from_bytes(sig_bytes);
verifying_key
.verify_strict(msg, &sig)
.verify(msg, &sig)
.map_err(|_| Error::SignatureInvalid)
}
@@ -113,8 +117,8 @@ impl<T: Serialize> Signed<T> {
}
mod sig_b64 {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Deserializer, Serializer};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserializer, Serializer, Deserialize};
pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
@@ -122,12 +126,8 @@ mod sig_b64 {
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
let s = String::deserialize(d)?;
let bytes = URL_SAFE_NO_PAD
.decode(&s)
.map_err(serde::de::Error::custom)?;
bytes
.try_into()
.map_err(|_| serde::de::Error::custom("expected 64 bytes"))
let bytes = URL_SAFE_NO_PAD.decode(&s).map_err(serde::de::Error::custom)?;
bytes.try_into().map_err(|_| serde::de::Error::custom("expected 64 bytes"))
}
}
@@ -158,102 +158,4 @@ mod tests {
let did = kp.did();
assert!(did.to_string().starts_with("did:key:z6Mk"));
}
#[test]
fn from_seed_round_trip() {
let kp = Keypair::generate();
let seed = kp.to_seed();
let kp2 = Keypair::from_seed(&seed).unwrap();
assert_eq!(kp.verifying_key(), kp2.verifying_key());
}
// `to_seed()` is the only seed accessor on `Keypair` (the raw, unzeroized
// `seed_bytes()` was removed in #41). Pin its return type so a bare-array
// accessor can't slip back in, and confirm it is deterministic. The
// from_seed -> verifying_key round-trip is already covered by
// `from_seed_round_trip`.
#[test]
fn to_seed_returns_zeroizing_and_is_deterministic() {
let kp = Keypair::generate();
// The type annotation is the regression guard: the seed must be handed
// out wrapped in `Zeroizing`, never as a bare `[u8; 32]`.
let seed: Zeroizing<[u8; 32]> = kp.to_seed();
assert_eq!(*kp.to_seed(), *seed); // stable across calls
}
#[test]
fn sign_b64_decodes_to_valid_signature() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let kp = Keypair::generate();
let msg = b"test payload for b64 signing";
let b64 = kp.sign_b64(msg);
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
.decode(&b64)
.expect("sign_b64 must produce valid base64url")
.try_into()
.expect("Ed25519 signature must be 64 bytes");
verify(&kp.verifying_key(), msg, &sig_bytes).unwrap();
}
#[test]
fn verify_rejects_wrong_key() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let msg = b"some message";
let sig = kp1.sign(msg);
let result = verify(&kp2.verifying_key(), msg, &sig.to_bytes());
assert!(
result.is_err(),
"signature from kp1 must not verify under kp2"
);
}
#[test]
fn verify_rejects_weak_key_signature() {
// Regression guard for strict verification: a signature forged under a
// weak (small-order) public key satisfies the verification equation
// but must be rejected. The identity point is such a key: with R the
// identity and S = 0, [S]B - [k]A is the identity for any message.
let mut weak_key_bytes = [0u8; 32];
weak_key_bytes[0] = 1; // compressed identity point (y = 1, x = 0)
let weak_vk = VerifyingKey::from_bytes(&weak_key_bytes).unwrap();
assert!(weak_vk.is_weak(), "identity point must be a weak key");
let msg = b"forged message";
let mut forged = [0u8; 64];
forged[0] = 1; // R = identity point, S = 0
let result = verify(&weak_vk, msg, &forged);
assert!(
result.is_err(),
"signature under a weak (small-order) public key must be rejected"
);
}
#[test]
fn signed_payload_round_trip() {
let kp = Keypair::generate();
let payload = serde_json::json!({"action": "push", "repo": "my-repo"});
let signed = Signed::new(payload, &kp).unwrap();
signed.verify(&kp.verifying_key()).unwrap();
}
#[test]
fn signed_payload_wrong_key_fails() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let payload = serde_json::json!({"action": "push"});
let signed = Signed::new(payload, &kp1).unwrap();
let result = signed.verify(&kp2.verifying_key());
assert!(
result.is_err(),
"Signed payload must not verify under a different key"
);
}
#[test]
fn from_pem_invalid_input_fails() {
let result = Keypair::from_pem("this is not valid PEM at all");
assert!(result.is_err());
}
}
+4 -15
View File
@@ -1,21 +1,10 @@
pub mod cert;
pub mod cid;
pub mod did;
pub mod encrypt;
pub mod error;
pub mod http_sig;
pub mod cid;
pub mod identity;
// `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
// pays. `test` is in the cfg so `cargo test -p gitlawb-core` still compiles and
// runs the matrix below with no feature selected; without it the tests would
// silently not run, which is the failure this module exists to prevent.
#[cfg(any(feature = "redirect", test))]
pub mod redirect;
pub mod sanitize;
pub mod scan_token;
pub mod http_sig;
pub mod ucan;
pub mod cert;
pub mod error;
pub use error::Error;
pub type Result<T> = std::result::Result<T, Error>;
-241
View File
@@ -1,241 +0,0 @@
//! Redirect policy shared by every Twigpine HTTP client that signs its requests.
//!
//! Both `gl` (async) and `git-remote-gitlawb` (blocking) attach RFC 9421
//! `Signature` and `Signature-Input` headers, and reqwest strips only
//! `Authorization`, `Cookie`, `Proxy-Authorization` and `WWW-Authenticate` when a
//! redirect crosses hosts. A signature would survive that hop, and it binds
//! `@method`, `@path` and `content-digest` with no authority component, so a node
//! answering 302 could hand a working credential to a host of its choosing and read
//! as the caller anywhere until the clock-skew window closes. On a 307/308 the
//! request body goes along with it, which for the remote helper is the pack.
//!
//! That same missing authority component is why the predicate also pins the
//! request-target: `@path` is signed as the client sent it and verified as the node
//! received it, so a same-origin hop that rewrites the path or the query (a
//! trailing-slash or query normalization) makes the signature cover a target the
//! node never saw and the read 401s. Only a hop that re-issues the identical target,
//! an http-to-https upgrade being the one that matters in practice, is followed.
//!
//! The decision lives here rather than in either client because the two used to
//! disagree: `gl` was scoped to the origin while the remote helper, the binary that
//! actually runs `git clone gitlawb://`, still ran reqwest's default and followed
//! anywhere. One predicate is what keeps a future third client from repeating that.
//!
//! The type is `url::Url`, which is what `reqwest::Url` re-exports, so both clients
//! pass their attempt URLs straight in.
/// Longest redirect chain followed. `reqwest::redirect::Policy::custom` replaces
/// reqwest's built-in limit, so the bound has to be restated by every client that
/// installs a custom policy; the value is reqwest's own default.
///
/// This counts FOLLOWS, matching `Policy::limited`: reqwest pushes the redirecting
/// URL onto `previous` before consulting the policy, and `Limit(max)` refuses once
/// `previous.len() > max`, so a caller comparing against this constant must use `>`
/// too or it permits one hop fewer than it says.
pub const MAX_REDIRECTS: usize = 10;
/// Follow a redirect only when it stays on the origin that issued it AND re-issues
/// the identical request-target.
///
/// `Policy::none()` would have been the simpler answer, but one same-origin redirect
/// shape is legitimate here: a node fronted by a proxy that upgrades http to https,
/// or otherwise re-issues the same path and query, so the policy is scoped rather
/// than switched off.
///
/// Path and query must match exactly, and that is the request-target clause rather
/// than an origin one. `@path` is signed as the client sent it and verified as the
/// node received it, so a hop that rewrites either half leaves a signature covering
/// a target nobody asked for and the node answers 401. Refusing the hop turns a
/// confusing 401 into the 3xx that names what actually happened. One policy covers
/// signed and unsigned callers alike, for the same reason the predicate is shared:
/// two rules would drift.
///
/// The clause pins `@path`, and only `@path`. A Twigpine signature also covers
/// `@method` and `content-digest`, and both of those are still open on a followed
/// hop: on a 301, 302 or 303, reqwest 0.12.28 delegates to tower-http's
/// `FollowRedirect`, which rewrites a POST to a GET and empties the body
/// (tower-http-0.6.8 `src/follow_redirect/mod.rs:273-285`), while its
/// `drop_payload_headers` removes only `Content-Type`, `Content-Length`,
/// `Content-Encoding` and `Transfer-Encoding`. So `Signature`, `Signature-Input` and
/// `Content-Digest` ride along on a request that no longer has the method or the body
/// they were computed over. Only 307 and 308 preserve both. This predicate returning
/// true therefore makes a GET-shaped hop safe to replay against the node's verifier
/// and says nothing about a bodied one: a signed write must not rely on it alone.
///
/// `Url::query` is `None` for `/a` and `Some("")` for `/a?`, and those are two
/// different request-targets on the node side too, so the comparison is strict and
/// needs no special case.
///
/// Host and port must match exactly. Port is compared as `Url::port`, which is
/// `None` for a scheme's default port, so http -> https on the same host compares
/// equal while http -> http on a different port does not. A downgrade from https to
/// http is refused as well: the target is the same host, but the signature would go
/// out in cleartext, which is the same credential leak by a slower route.
///
/// Host comparison rides on `url`'s parse-time normalization (lowercasing and IDN
/// -> punycode), so the spellings an attacker reaches for do not open a gap. That
/// is a property of the parsed `Url`, not of this function, which is why the test
/// matrix pins it: a move to raw string comparison would silently lose it.
pub fn may_follow(previous: &url::Url, next: &url::Url) -> bool {
let same_origin = next.host_str() == previous.host_str() && next.port() == previous.port();
let same_target = next.path() == previous.path() && next.query() == previous.query();
let downgraded = previous.scheme() == "https" && next.scheme() != "https";
same_origin && same_target && !downgraded
}
#[cfg(test)]
mod tests {
use super::*;
/// Every branch of the decision, both ways, plus the spellings that would slip
/// past a comparison less careful than `Url`'s own normalization.
#[test]
fn may_follow_covers_each_origin_branch() {
let url = |s: &str| url::Url::parse(s).unwrap();
let cases: &[(&str, &str, bool, &str)] = &[
(
"http://node.example/a",
"http://node.example/b",
false,
"same origin but the path changed: @path is signed as sent and verified as received",
),
(
"http://node.example/a",
"https://node.example/a",
true,
"http to https on one host with an identical request-target: both ports are \
the scheme default, so the proxy upgrade is still followed",
),
(
"https://node.example/a",
"https://node.example/a/",
false,
"trailing-slash normalization changes the request-target, so it is refused",
),
(
"https://node.example:8443/a",
"https://node.example:8443/a",
true,
"same explicit port",
),
(
"http://node.example/a",
"http://attacker.example/a",
false,
"different host",
),
(
"http://node.example/a",
"http://node.example:8080/a",
false,
"same host, different port",
),
(
"https://node.example/a",
"http://node.example/a",
false,
"https downgraded to cleartext on the same host",
),
(
"https://node.example/a",
"http://node.example:443/a",
false,
"a downgrade dressed up as the https port",
),
// The rows below pass today because `Url::parse` normalizes the host, not
// because anything here compares case-insensitively or decodes IDN. They
// are the variants an attacker reaches for, so they are pinned: swapping
// this predicate for a raw string comparison must break the suite.
// Each of these pairs an IDENTICAL path on both sides, deliberately. The
// request-target clause below would make every one of them false on the
// path alone, and a row that is false for two reasons has stopped pinning
// either. With the paths equal, the host or port comparison is the only
// thing left that can decide them.
(
"https://node.example/a",
"https://NODE.EXAMPLE/a",
true,
"same host in a different case: parse lowercases it",
),
(
"https://node.example/a",
"https://node.example./a",
false,
"a trailing dot is a different host to url, so the redirect is refused",
),
(
"https://exämple.test/a",
"https://xn--exmple-cua.test/a",
true,
"unicode host and its punycode spelling are one host after parse",
),
(
"https://node.example/a",
"https://user:[email protected]/a",
true,
"userinfo is not part of the origin: same host, still followed",
),
(
"https://node.example/a",
"https://[email protected]/a",
false,
"the node's name smuggled into userinfo: the host is the attacker's",
),
(
"https://node.example/a",
"https://attacker.example/a#node.example",
false,
"the node's name pushed into the fragment: the host is the attacker's",
),
// The request-target clause, both directions. `@path` is the only thing a
// Twigpine signature binds the request to, so a hop that rewrites it hands
// the node a signature over a target it never received.
(
"https://node.example/a?x=1",
"https://node.example/a?x=1",
true,
"identical request-target: the same-origin hop that is still followed",
),
(
"https://node.example/a?x=1",
"https://node.example/a?x=2",
false,
"same path but a different query: the request-target covers the query too",
),
(
"https://node.example/a",
"https://node.example/a?x=1",
false,
"a query added where there was none",
),
(
"http://node.example/a",
"http://node.example/a?",
false,
"an empty query added where there was none: a missing query and an empty \
one are different request-targets",
),
(
"https://node.example/a?x=1",
"https://node.example/a",
false,
"the query dropped where there was one: the comparison is symmetric, and \
nothing else in the matrix pins that direction",
),
(
"https://node.example/a#x",
"https://node.example/a#y",
true,
"a fragment-only difference is still followed: a fragment never reaches \
the wire, so it is no part of the request-target the node verifies",
),
];
for (previous, next, expected, why) in cases {
assert_eq!(
may_follow(&url(previous), &url(next)),
*expected,
"{previous} -> {next} ({why})"
);
}
}
}
-69
View File
@@ -1,69 +0,0 @@
//! Text sanitization predicates shared across the workspace.
//!
//! Untrusted peer/remote bytes that reach an operator's terminal (node error
//! bodies, node-advertised strings) must be stripped of BOTH `Cc` control bytes
//! (`char::is_control()`) AND `Cf` bidi/format controls before display: a
//! right-to-left override or directional isolate can visually reorder the line
//! and spoof the text (Trojan Source, CWE-451). This is INV-6.
//!
//! `is_bidi_format` is the single workspace definition of the second half of
//! that obligation. It was split out of `gl`'s local copy so the two terminal
//! sanitizers that shipped with only the `Cc` half (#183) — and any future
//! sanitizer — reuse one audited predicate instead of re-deriving the set.
/// True for the Unicode bidirectional/directional-isolate format characters
/// (the reordering subset of category `Cf`) that can visually reorder a terminal
/// line. These are NOT matched by [`char::is_control()`], which covers `Cc` only.
///
/// This is deliberately the reordering subset, not the whole `Cf` category:
/// legitimate `Cf` characters such as ZWJ (`U+200D`, emoji sequences) and the
/// soft hyphen (`U+00AD`) are NOT matched, so honest international text passes
/// through unchanged. Widening this set is a behavior change for every consumer.
///
/// Pair it with `!c.is_control()` in a terminal-bound sanitizer (INV-6); see the
/// wrappers in `gl`, `git-remote-gitlawb`, and `icaptcha-client`.
pub fn is_bidi_format(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}' | '\u{061C}' // LRM, RLM, ALM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_every_reordering_code_point() {
// Asserted individually so a partial predicate (e.g. one that covers the
// marks but not the isolates) fails loudly rather than passing.
for c in [
'\u{200E}', '\u{200F}', '\u{061C}', // LRM, RLM, ALM
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', // LRE, RLE, PDF, LRO, RLO
'\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // LRI, RLI, FSI, PDI
] {
assert!(
is_bidi_format(c),
"U+{:04X} must be a bidi-format char",
c as u32
);
}
}
#[test]
fn passes_legitimate_and_control_chars() {
// Must NOT over-strip: plain text, a genuine RTL SCRIPT letter (Lo, not a
// format char), ZWJ (legitimate Cf), and soft hyphen (legitimate Cf) are
// not bidi-format. ESC is a Cc control char — owned by is_control(), not
// this predicate, which is bidi-only.
for c in ['a', '\u{0627}', '\u{200D}', '\u{00AD}', '\u{1b}'] {
assert!(
!is_bidi_format(c),
"U+{:04X} must NOT be a bidi-format char",
c as u32
);
}
}
}
-499
View File
@@ -1,499 +0,0 @@
//! Sealed continuation token for the node's bounded legacy CID scan (INV-13).
//!
//! The `/ipfs/{cid}` resolver's legacy scan stops at a row ceiling and sheds a
//! retryable 503. To let a holder buried past that ceiling still be reached, the
//! shed carries the scan position so the caller can echo it back and resume. The
//! whole point of the design is that the node keeps NO server-side scan state: the
//! position rides in the caller's token.
//!
//! That makes the token an EMITTED continuation derived from a FETCHED row, and on
//! a scan that served nothing every fetched row is by construction a private or
//! quarantined repo the caller may not read. The row's `created_at` leaks its
//! creation time and its `id` carries the owner's DID, so both halves are withheld
//! fields and the token must be CONFIDENTIAL, not merely tamper-evident:
//!
//! * AEAD-sealed (XChaCha20-Poly1305), never base64-of-plaintext and never
//! signed plaintext. Integrity is not confidentiality.
//! * A fresh `OsRng` nonce on EVERY seal. Under a stream cipher a repeated nonce
//! means repeated keystream, and an attacker who can force the node to seal a
//! position whose plaintext they know XORs two tokens and recovers a withheld
//! row's fields in full, strictly worse than emitting plaintext.
//! * FIXED-WIDTH plaintext. AEAD ciphertext is plaintext-length plus the tag, and
//! every field of a scan position varies in length, so a variable encoding would
//! make token LENGTH a side channel for the sealed row (a short name under a
//! short owner vs a long one) and for the candidate oid's width (40 hex on a
//! sha1 repo, 64 on a sha256 one). Every token this module mints is byte-identical
//! in length. Each field is padded to its own separate width, which is a per-field
//! constant and so still leaks nothing about a given row or candidate.
//! * The canonical CID as associated data, so a token minted while scanning for
//! one CID does not authenticate when replayed against another.
//!
//! Every failure to open (wrong key, tampered bytes, wrong CID, expired, malformed)
//! returns the same `None`. The caller treats that as "no token" and starts at the
//! front, so no failure class is distinguishable and the token is no oracle.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine};
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng, Payload},
XChaCha20Poly1305, XNonce,
};
use rand::RngCore;
/// Keyset position of the last row a truncated scan fetched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanPosition {
/// The row's raw stored `created_at` text, the first half of the keyset cursor.
pub created_at_key: String,
/// The row's `id`, the tiebreaking half of the keyset cursor.
pub id: String,
/// The oid hex of the CANDIDATE this position resumes. One CID can map to several
/// git oids, so a bare row cursor names a row without naming whose walk it belongs
/// to. The candidate is named by IDENTITY rather than by position in the candidate
/// list: that list is ordered by hex and mutates between rungs, so an index would
/// name a different candidate the moment anything is pinned or unpinned.
pub sha256_hex: String,
}
/// Plaintext version byte, so a future layout change is a clean open-failure
/// (treated as absent) rather than a misparse.
///
/// Bumped to 2 when the two halves stopped sharing one width (see [`ID_WIDTH`]), and
/// to 3 when the position gained the candidate oid it resumes (see [`OID_WIDTH`]).
/// A token minted under an earlier layout is a different length and a different
/// framing, so it opens to `None` and the caller restarts at the front, which is the
/// safe direction: a misparse would resume at a fabricated row and skip coverage.
const VERSION: u8 = 3;
/// Byte width the `created_at` half is padded to. Every value stored here is a
/// serialized timestamp, about 30 bytes, so 64 is roomy for the field's whole domain.
/// It is deliberately NOT widened to match [`ID_WIDTH`]: padding both halves to the id
/// width would nearly double every token for a field that can never use the space.
const CREATED_WIDTH: usize = 64;
/// Byte width the `id` half is padded to.
///
/// The bound is set by the WRITERS, not by what a typical id happens to look like.
/// `upsert_mirror_repo` builds `repos.id` as `{owner}/{name}`, and the slug validators
/// in the node's `repo_store` admit an owner of up to 255 bytes and a name of up to
/// 100, so 356 bytes is reachable through the ordinary write path and repo names are
/// peer-controllable. 384 clears that with margin.
///
/// Under-sizing this is not a cosmetic bug. A row at a truncation boundary whose id
/// exceeds the width fails the seal, the handler sheds a 503 with no continuation, and
/// a tokenless shed is byte-identical to the wrapped-scan response whose contract is
/// "the absence of a token means the ladder is over". The boundary row is deterministic
/// for a stable inventory, so every retry reproduces it and every row past it becomes
/// permanently unreachable. Anything past the width still fails loudly rather than
/// silently truncating a cursor into one that resumes at the wrong row.
const ID_WIDTH: usize = 384;
/// Byte width the candidate oid half is padded to.
///
/// Git mints exactly two oid widths and this field carries BOTH. A production repo is
/// created by `store::init_bare` with `git init --bare --object-format=sha1`, so its
/// oids are 40 hex; only the sha256 test fixtures mint 64. The field is therefore
/// length-prefixed like the two row halves rather than a bare fixed 64: a 64-only
/// field would fail every seal on a real deployment, and a failed seal sheds a
/// tokenless 503 that is byte-identical to "your ladder is over".
///
/// The padding to 64 is what keeps the WIDTH off the wire. Without it a 40-hex token
/// is 24 bytes shorter than a 64-hex one, and token length would say which object
/// format the holder's repo uses.
const OID_WIDTH: usize = 64;
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
const PLAINTEXT_LEN: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 2 + OID_WIDTH + 8;
/// Nonce width for XChaCha20-Poly1305.
const NONCE_LEN: usize = 24;
/// A fresh random 32-byte sealing key from the OS CSPRNG.
pub fn new_key() -> [u8; 32] {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
// The two halves below are deliberately separate and adjacent. The framing pair owns
// "every token is the same length"; the AEAD pair owns "the contents are confidential
// and CID-bound". Keeping them apart is what lets each property be exercised (and
// broken) without disturbing the other.
/// Encode a position into the FIXED-WIDTH plaintext:
/// `version | created_len:u16 | created[CREATED_WIDTH] | id_len:u16 | id[ID_WIDTH] | oid_len:u16 | oid[OID_WIDTH] | expires:i64`
///
/// The padding is the point. AEAD ciphertext is plaintext-length plus the tag, and every
/// field of a scan position varies in length, so a length-prefixed encoding with no
/// padding would make token LENGTH a side channel for the sealed row and for the
/// candidate's object format. Each field is padded to its OWN fixed width, which keeps
/// every minted token the same length while letting the id half carry the range the
/// write path actually admits.
fn encode_position(pos: &ScanPosition, expires_at_unix: i64) -> anyhow::Result<Vec<u8>> {
let mut out = vec![0u8; PLAINTEXT_LEN];
out[0] = VERSION;
let mut at = 1;
for (field, width) in [
(pos.created_at_key.as_bytes(), CREATED_WIDTH),
(pos.id.as_bytes(), ID_WIDTH),
(pos.sha256_hex.as_bytes(), OID_WIDTH),
] {
if field.len() > width {
// Loud rather than truncating: a clipped cursor resumes at the wrong row and
// silently skips coverage, which is the availability half of the bug this
// token exists to fix.
anyhow::bail!(
"scan token field is {} bytes, over the {width}-byte fixed width",
field.len()
);
}
out[at..at + 2].copy_from_slice(&(field.len() as u16).to_le_bytes());
at += 2;
out[at..at + field.len()].copy_from_slice(field);
at += width;
}
out[at..at + 8].copy_from_slice(&expires_at_unix.to_le_bytes());
Ok(out)
}
/// Decode what [`encode_position`] wrote. `None` on any structural mismatch.
fn decode_position(bytes: &[u8]) -> Option<(ScanPosition, i64)> {
if bytes.len() != PLAINTEXT_LEN || bytes[0] != VERSION {
return None;
}
let mut at = 1;
let mut fields = [const { String::new() }; 3];
for (slot, width) in fields.iter_mut().zip([CREATED_WIDTH, ID_WIDTH, OID_WIDTH]) {
let len = u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
at += 2;
if len > width {
return None;
}
*slot = String::from_utf8(bytes[at..at + len].to_vec()).ok()?;
at += width;
}
let expires_at = i64::from_le_bytes(bytes[at..at + 8].try_into().ok()?);
let [created_at_key, id, sha256_hex] = fields;
// A zero-length candidate is a third state the encoder never mints. The front-of-table
// sentinel is empty ROW halves with a real oid, so an empty oid would hand the resume
// path a candidate that names nothing; refuse it like every other malformed frame.
if sha256_hex.is_empty() {
return None;
}
Some((
ScanPosition {
created_at_key,
id,
sha256_hex,
},
expires_at,
))
}
/// AEAD-seal `plaintext` under `key`, bound to `cid`, framed as `nonce || ciphertext`.
fn seal_bytes(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("scan token key: {e}"))?;
// A FRESH nonce per seal, from the OS CSPRNG. Under a stream cipher a repeated nonce
// repeats the keystream, and two tokens sealed under one nonce XOR to the difference
// of their plaintexts, which recovers a withheld row in full when the attacker can
// force one of the two positions. This draw is the property the whole confidentiality
// claim rests on.
let mut nonce = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: plaintext,
// The canonical CID as associated data: a token minted while scanning for
// one CID does not authenticate against another, so it cannot be replayed
// to seed a different scan.
aad: cid.as_bytes(),
},
)
.map_err(|e| anyhow::anyhow!("scan token seal: {e}"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
/// Open what [`seal_bytes`] framed. `None` on any failure, including a wrong `cid`.
fn open_bytes(key: &[u8; 32], cid: &str, raw: &[u8]) -> Option<Vec<u8>> {
if raw.len() <= NONCE_LEN + 16 {
return None;
}
let (nonce, sealed) = raw.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new_from_slice(key).ok()?;
cipher
.decrypt(
XNonce::from_slice(nonce),
Payload {
msg: sealed,
aad: cid.as_bytes(),
},
)
.ok()
}
/// Seal `pos` under `key`, bound to `cid`, expiring at `expires_at_unix`.
///
/// Returns the base64url (no pad) token. Errors only when a field exceeds
/// its own fixed width ([`CREATED_WIDTH`], [`ID_WIDTH`], [`OID_WIDTH`]) or the AEAD
/// itself fails, never silently truncates.
pub fn seal_scan_token(
key: &[u8; 32],
cid: &str,
pos: &ScanPosition,
expires_at_unix: i64,
) -> anyhow::Result<String> {
let plaintext = encode_position(pos, expires_at_unix)?;
Ok(B64URL.encode(seal_bytes(key, cid, &plaintext)?))
}
/// Open a token minted by [`seal_scan_token`] under the same key and CID.
///
/// `None` for every failure class alike (wrong key, tampered, foreign CID, expired,
/// malformed, wrong version), so the caller can treat all of them as "absent" without
/// leaking which one occurred.
pub fn open_scan_token(
key: &[u8; 32],
cid: &str,
token: &str,
now_unix: i64,
) -> Option<ScanPosition> {
let raw = B64URL.decode(token).ok()?;
let plaintext = open_bytes(key, cid, &raw)?;
let (pos, expires_at) = decode_position(&plaintext)?;
if now_unix >= expires_at {
return None;
}
Some(pos)
}
#[cfg(test)]
mod tests {
use super::*;
/// A production-shaped candidate: `git init --bare --object-format=sha1`, so 40 hex.
const OID_40: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
/// A test-fixture-shaped candidate: the sha256 repos the suite creates mint 64 hex.
const OID_64: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn pos(created: &str, id: &str) -> ScanPosition {
pos_for(created, id, OID_40)
}
fn pos_for(created: &str, id: &str, sha256_hex: &str) -> ScanPosition {
ScanPosition {
created_at_key: created.to_string(),
id: id.to_string(),
sha256_hex: sha256_hex.to_string(),
}
}
/// Seal a hand-built plaintext through the AEAD half, so a test can frame bytes the
/// encoder would never mint (an old version byte, a zero-length oid) and still exercise
/// the real open path.
fn seal_raw(key: &[u8; 32], cid: &str, plaintext: &[u8]) -> String {
B64URL.encode(seal_bytes(key, cid, plaintext).unwrap())
}
/// Byte offset of the oid length prefix inside the plaintext, derived from the widths
/// rather than hardcoded so a width change moves it with the layout.
const OID_LEN_AT: usize = 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH;
/// Scenario 1: both of git's oid widths round trip. The 40-hex case is the PRODUCTION
/// shape (`git init --bare --object-format=sha1`), and an all-64 fixture suite would
/// never exercise it, which is exactly how a fixed-64 field would ship broken.
#[test]
fn round_trips_at_both_oid_widths() {
let key = new_key();
for hex in [OID_40, OID_64] {
let p = pos_for("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo", hex);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("both oid widths must seal");
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 0),
Some(p),
"a {}-hex candidate must open to the identical position",
hex.len()
);
}
}
/// Scenario 2: a token framed under the OLD version opens to `None`, never a misparse.
/// Both legs matter: the version-2 plaintext was a different LENGTH, and a future
/// same-length layout would only be caught by the version byte itself.
#[test]
fn a_prior_layout_version_opens_to_none() {
let key = new_key();
// The version-2 layout verbatim: no oid field, so 461 bytes.
let mut old = vec![0u8; 1 + 2 + CREATED_WIDTH + 2 + ID_WIDTH + 8];
old[0] = 2;
assert_eq!(old.len(), 461, "the version-2 plaintext was 461 bytes");
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &old), 0),
None,
"a version-2 token must open to None so the caller restarts at the front"
);
// Same length, stale version byte: only the version check can refuse this one.
let mut stamped = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
stamped[0] = VERSION - 1;
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &stamped), 0),
None,
"a stale version byte must open to None even at the current width"
);
}
/// Scenario 3: token length is invariant across candidate VALUE and candidate WIDTH.
/// The padding is what keeps the oid width off the wire: without it a 40-hex token is
/// 24 bytes shorter than a 64-hex one and the length says which repo format the
/// holder uses.
#[test]
fn token_length_is_invariant_across_oid_widths() {
let key = new_key();
let created = "2020-01-01T00:00:00+00:00";
let id = "z6MkOwner/private-repo";
let short =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_40), 1 << 40).unwrap();
let long =
seal_scan_token(&key, "bafkcid", &pos_for(created, id, OID_64), 1 << 40).unwrap();
assert_eq!(
short.len(),
long.len(),
"a 40-hex and a 64-hex candidate must mint tokens of identical length, or the \
oid width is a side channel"
);
// The absolute width, pinned by execution rather than by arithmetic on paper. The
// gl client's mock fixtures hardcode this number (`TOKEN_LEN` in
// crates/gl/src/ipfs_cmd.rs), and nothing in that crate seals a real token, so this
// assertion is the only executable check that the two agree.
assert_eq!(
short.len(),
756,
"24 nonce + {PLAINTEXT_LEN} plaintext + 16 tag, base64url no pad"
);
}
/// Scenario 4: the front-of-table sentinel. Empty row halves with a REAL candidate
/// round trip, which is what lets a seal say "this candidate, no row cursor yet".
#[test]
fn empty_row_fields_round_trip_with_a_real_candidate() {
let key = new_key();
let p = pos_for("", "", OID_40);
let t =
seal_scan_token(&key, "bafkcid", &p, 1 << 40).expect("the front sentinel must seal");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
/// Scenario 6, third leg: a zero-length oid is a distinguishable third state that the
/// encoder never mints, and accepting it would hand the sentinel machinery a candidate
/// naming nothing. The decode path refuses it.
#[test]
fn a_zero_length_candidate_opens_to_none() {
let key = new_key();
let mut plaintext = encode_position(
&pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo"),
1 << 40,
)
.unwrap();
plaintext[OID_LEN_AT..OID_LEN_AT + 2].copy_from_slice(&0u16.to_le_bytes());
assert_eq!(
open_scan_token(&key, "bafkcid", &seal_raw(&key, "bafkcid", &plaintext), 0),
None,
"a zero-length candidate must open to None, not to a position naming nothing"
);
}
/// Scenario 6, second leg: an oid past the 64-byte width fails the seal loudly rather
/// than being clipped into a hex that names a different candidate.
#[test]
fn an_oid_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos_for(
"2020-01-01T00:00:03+00:00",
"z6MkOwner/private-repo",
&"a".repeat(OID_WIDTH + 1),
);
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide candidate must fail the seal, never be truncated into a hex that \
resumes the wrong candidate"
);
}
#[test]
fn round_trips_under_the_same_key_and_cid() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn every_failure_class_opens_to_none() {
let key = new_key();
let other = new_key();
let p = pos("2020-01-01T00:00:03+00:00", "z6MkOwner/private-repo");
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40).unwrap();
assert_eq!(open_scan_token(&other, "bafkcid", &t, 0), None, "wrong key");
assert_eq!(
open_scan_token(&key, "bafkOTHER", &t, 0),
None,
"foreign CID"
);
assert_eq!(
open_scan_token(&key, "bafkcid", &t, 1 << 41),
None,
"expired"
);
assert_eq!(open_scan_token(&key, "bafkcid", "!!not b64", 0), None);
assert_eq!(open_scan_token(&key, "bafkcid", "", 0), None);
let mut flipped: Vec<u8> = t.bytes().collect();
let last = flipped.len() - 1;
flipped[last] = if flipped[last] == b'A' { b'B' } else { b'A' };
assert_eq!(
open_scan_token(&key, "bafkcid", &String::from_utf8(flipped).unwrap(), 0),
None,
"tampered"
);
}
/// The id half must clear the LARGEST repo id the node's own write path admits,
/// not merely a typical one. `upsert_mirror_repo` builds `repos.id` as
/// `{owner}/{name}`, and the slug validators in `repo_store` admit 255 bytes of
/// owner and 100 of name, so 356 is reachable. A width under that turns the
/// boundary row into a seal failure, which sheds a tokenless 503 that is
/// byte-identical to "your ladder is over" and strands every row past it forever.
#[test]
fn round_trips_a_repo_id_at_the_write_paths_maximum() {
let key = new_key();
let id = format!("{}/{}", "o".repeat(255), "n".repeat(100));
assert_eq!(id.len(), 356, "255 owner + '/' + 100 name");
let p = pos("2020-01-01T00:00:03+00:00", &id);
let t = seal_scan_token(&key, "bafkcid", &p, 1 << 40)
.expect("a repo id the write path admits must seal, never fail the width");
assert_eq!(open_scan_token(&key, "bafkcid", &t, 0), Some(p));
}
#[test]
fn a_field_over_the_fixed_width_fails_loudly() {
let key = new_key();
let p = pos("2020-01-01T00:00:03+00:00", &"x".repeat(ID_WIDTH + 1));
assert!(
seal_scan_token(&key, "bafkcid", &p, 1 << 40).is_err(),
"an over-wide field must fail the seal, never be truncated into a cursor \
that resumes at the wrong row"
);
}
}
+23 -310
View File
@@ -1,7 +1,7 @@
//! UCAN (User Controlled Authorization Networks) — capability token types.
//!
//! UCANs let a DID delegate specific capabilities to another DID,
//! with optional expiry and revocation. Twigpine uses UCANs for:
//! with optional expiry and revocation. gitlawb uses UCANs for:
//! - Delegating push access to a branch to a CI agent
//! - Granting a reviewer the ability to approve PRs
//! - Bootstrap tokens issued at registration
@@ -12,9 +12,9 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// A UCAN capability: what resource the token grants access to.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -30,34 +30,16 @@ pub struct Capability {
impl Capability {
pub fn new(with: impl Into<String>, can: impl Into<String>) -> Self {
Self {
with: with.into(),
can: can.into(),
constraints: None,
}
Self { with: with.into(), can: can.into(), constraints: None }
}
pub fn with_constraints(mut self, constraints: serde_json::Value) -> Self {
self.constraints = Some(constraints);
self
}
/// Returns `true` if `self` is a valid attenuation of `parent`.
///
/// A delegated capability is only valid if it is at most as permissive as
/// the parent capability backing it. `"*"` on the **parent**'s resource or
/// 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.
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
}
}
/// Well-known Twigpine capability strings.
/// Well-known gitlawb capability strings.
pub mod caps {
pub const GIT_PUSH: &str = "git/push";
pub const GIT_FETCH: &str = "git/fetch";
@@ -146,40 +128,18 @@ impl Ucan {
}
}
/// 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 {
Utc::now().timestamp() < nbf
} else {
false
}
}
/// Verify this UCAN's audience matches `expected`.
pub fn verify_audience(&self, expected: &Did) -> Result<()> {
if &self.payload.aud != expected {
return Err(Error::Ucan(format!(
"audience mismatch: expected {expected}, got {}",
self.payload.aud
)));
}
Ok(())
}
/// Verify the signature on this UCAN.
pub fn verify_signature(&self) -> Result<()> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use crate::identity::verify;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let vk = self.payload.iss.to_verifying_key()?;
let signing_bytes = serde_json::to_vec(&self.payload)?;
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&self.s)
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&self.s)
.map_err(|e| Error::Ucan(format!("invalid base64 signature: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
.map_err(|_| Error::Ucan("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)
@@ -187,16 +147,9 @@ impl Ucan {
}
/// Check if this UCAN grants a specific capability on a resource.
///
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
/// requested resource/action, since a valid delegation chain can produce
/// exactly that capability (see `is_attenuated_by`).
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload.att.iter().any(|cap| {
let resource_ok = cap.with == resource || cap.with == "*";
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
resource_ok && action_ok
cap.with == resource && cap.can == action
})
}
@@ -257,10 +210,6 @@ impl Ucan {
return Err(Error::Ucan("token is expired".to_string()));
}
if self.is_before_valid() {
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}")))?;
@@ -273,17 +222,6 @@ impl Ucan {
)));
}
// 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()?;
}
@@ -307,8 +245,7 @@ mod tests {
audience.clone(),
vec![Capability::new("gitlawb://repos/test/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
ucan.verify_signature().unwrap();
assert!(!ucan.is_expired());
@@ -318,63 +255,6 @@ mod tests {
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_resource_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("*", caps::GIT_PUSH)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
}
#[test]
fn can_honors_repo_admin_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new(
"gitlawb://repos/test/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn can_honors_action_wildcard() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
vec![Capability::new("gitlawb://repos/test/repo", "*")],
None,
)
.unwrap();
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
}
#[test]
fn bootstrap_ucan() {
let issuer = Keypair::generate();
@@ -411,12 +291,10 @@ mod tests {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
&issuer, audience,
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Root UCAN (no proofs) should verify fine
ucan.verify_chain().unwrap();
}
@@ -429,22 +307,18 @@ mod tests {
// Alice grants Bob push access
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Bob delegates to Charlie (with proof from Alice)
let delegated = Ucan::delegate(
&bob,
charlie.did(),
&bob, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Chain should verify: Charlie's token → Bob's proof → Alice signed it
delegated.verify_chain().unwrap();
@@ -460,22 +334,18 @@ mod tests {
// Alice grants Bob access
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Eve (NOT Bob) tries to delegate using Alice's proof
let bad = Ucan::delegate(
&eve,
charlie.did(),
&eve, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Should fail: proof audience (Bob) != UCAN issuer (Eve)
let err = bad.verify_chain().unwrap_err();
@@ -491,177 +361,20 @@ mod tests {
// Alice grants Bob access with expiry in the past
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
Some(exp),
)
.unwrap();
).unwrap();
let delegated = Ucan::delegate(
&bob,
charlie.did(),
&bob, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Should fail: the proof is expired
let err = delegated.verify_chain().unwrap_err();
assert!(err.to_string().contains("expired"));
}
#[test]
fn is_before_valid_future_nbf() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let nbf_future = chrono::Utc::now() + chrono::Duration::hours(1);
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: issuer.did(),
aud: audience,
att: vec![],
exp: None,
nbf: Some(nbf_future.timestamp()),
prf: vec![],
};
let signing_bytes = serde_json::to_vec(&payload).unwrap();
let sig = issuer.sign_b64(&signing_bytes);
let ucan = Ucan { payload, s: sig };
assert!(ucan.is_before_valid());
let err = ucan.verify_chain().unwrap_err();
assert!(err.to_string().contains("not yet valid"));
}
#[test]
fn is_before_valid_past_nbf() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let nbf_past = chrono::Utc::now() - chrono::Duration::hours(1);
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: issuer.did(),
aud: audience,
att: vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
exp: None,
nbf: Some(nbf_past.timestamp()),
prf: vec![],
};
let signing_bytes = serde_json::to_vec(&payload).unwrap();
let sig = issuer.sign_b64(&signing_bytes);
let ucan = Ucan { payload, s: sig };
assert!(!ucan.is_before_valid());
ucan.verify_chain().unwrap();
}
#[test]
fn verify_audience_matches() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(&issuer, audience.clone(), vec![], None).unwrap();
ucan.verify_audience(&audience).unwrap();
}
#[test]
fn verify_audience_mismatch() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let wrong = Keypair::generate().did();
let ucan = Ucan::issue(&issuer, audience, vec![], None).unwrap();
let err = ucan.verify_audience(&wrong).unwrap_err();
assert!(err.to_string().contains("audience mismatch"));
}
#[test]
fn attenuation_valid_subset() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob push on a specific repo
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
// Bob delegates the same capability (exact subset) to Charlie
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
delegated.verify_chain().unwrap();
}
#[test]
fn attenuation_exceeds_parent_is_rejected() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob push on one repo only
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
// Bob tries to delegate merge (not in the original grant) to Charlie
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::PR_MERGE)],
None,
&root,
)
.unwrap();
let err = delegated.verify_chain().unwrap_err();
assert!(err.to_string().contains("attenuation violated"));
}
#[test]
fn attenuation_repo_admin_covers_all() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob repo/admin (superpower)
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new(
"gitlawb://repos/org/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
// Bob delegates a more specific capability — covered by repo/admin
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
delegated.verify_chain().unwrap();
}
}
+5 -28
View File
@@ -1,9 +1,8 @@
[package]
name = "gitlawb-node"
description = "The Twigpine node daemon — git hosting over HTTP with DID auth"
version = "0.7.1" # x-release-please-version
description = "The gitlawb node daemon — git hosting over HTTP with DID auth"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
@@ -12,8 +11,6 @@ path = "src/main.rs"
[dependencies]
gitlawb-core = { path = "../gitlawb-core" }
ed25519-dalek = { workspace = true }
base64 = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -29,11 +26,9 @@ async-graphql-axum = "7"
tokio-stream = { version = "0.1", features = ["sync"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "limit"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid", "macros", "migrate"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "chrono", "uuid"] }
clap = { version = "4", features = ["derive", "env"] }
bytes = "1"
libc = "0.2"
zeroize = "1"
cid = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
@@ -41,28 +36,14 @@ hmac = { workspace = true }
http-body-util = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
dirs-next = "2"
# NFC-normalize visibility globs/paths at the matcher seam so a deny rule
# withholds a canonically-equivalent path regardless of NFC/NFD form (#101).
unicode-normalization = "0.1"
reqwest = { workspace = true }
libp2p-core = "0.44"
libp2p-gossipsub = "0.50.1"
libp2p-identify = "0.48"
libp2p-identity = { version = "0.3", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.49"
libp2p-quic = { version = "0.14", features = ["tokio"] }
libp2p-swarm = { version = "0.48", features = ["macros", "tokio"] }
multiaddr = "0.19"
libp2p = { version = "0.55", features = ["gossipsub", "kad", "mdns", "noise", "tcp", "tokio", "yamux", "identify", "macros"] }
futures = "0.3"
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-sdk-s3 = "1"
aws-config = { version = "1", features = ["behavior-version-latest"] }
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
tar = "0.4"
zstd = "0.13"
# Prometheus metrics. Used to expose a /metrics endpoint for ops/observability
# on the opt-in GITLAWB_METRICS_ADDR listener. The crate is also the de-facto
# exposition format encoder in the Rust ecosystem.
prometheus = { version = "0.13", default-features = false }
alloy = { version = "1", default-features = false, features = [
"contract",
"provider-http",
@@ -73,11 +54,7 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.45.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
tempfile = "3"
# `test-util` for `#[tokio::test(start_paused = true)]`: lets the deadline tests assert
# on which timer fired instead of on wall clock.
tokio = { workspace = true, features = ["test-util"] }
File diff suppressed because it is too large. Load diff
+20 -186
View File
@@ -2,52 +2,12 @@
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
fn normalize_agent_did(did: &str) -> String {
if did.starts_with("did:") {
did.to_string()
} else {
format!("did:key:{did}")
}
}
fn agent_key_segment(did: &str) -> &str {
did.split(':').next_back().unwrap_or(did)
}
/// Whether `caller` (the verified `AuthenticatedDid`) is the same identity as
/// `target` (the DID being acted on), tolerant of full `did:key:...` vs short
/// key-only forms on either side. Used to gate self-deregistration so a DID
/// can only retire itself.
fn caller_matches_did(caller: &str, target: &str) -> bool {
agent_key_segment(caller) == agent_key_segment(target)
}
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
let normalized_did = normalize_agent_did(did);
if state.db.get_agent(&normalized_did).await?.is_some() {
return Ok(normalized_did);
}
let requested_key = agent_key_segment(&normalized_did);
let matching_agent = state
.db
.list_agents(None)
.await?
.into_iter()
.find(|agent| agent_key_segment(&agent.did).starts_with(requested_key));
Ok(matching_agent
.map(|agent| agent.did)
.unwrap_or(normalized_did))
}
#[derive(Debug, Serialize)]
pub struct TrustResponse {
pub did: String,
@@ -80,8 +40,6 @@ pub struct AgentResponse {
pub capabilities: Vec<String>,
pub registered_at: String,
pub last_seen: Option<String>,
/// Lifecycle status: `active` or `revoked`.
pub status: String,
}
/// GET /api/v1/agents
@@ -90,17 +48,13 @@ pub async fn list_agents(
Query(params): Query<AgentListQuery>,
) -> Result<Json<serde_json::Value>> {
let agents = state.db.list_agents(params.capability.as_deref()).await?;
let list: Vec<AgentResponse> = agents
.into_iter()
.map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
status: a.status,
})
.collect();
let list: Vec<AgentResponse> = agents.into_iter().map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
}).collect();
Ok(Json(serde_json::json!({ "agents": list })))
}
@@ -109,23 +63,15 @@ pub async fn show_agent(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<(StatusCode, Json<AgentResponse>)> {
let normalized_did = resolve_agent_did(&state, &did).await?;
let agent = state
.db
.get_agent(&normalized_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {normalized_did} not found")))?;
Ok((
StatusCode::OK,
Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
status: agent.status,
}),
))
let agent = state.db.get_agent(&did).await?
.ok_or_else(|| AppError::NotFound(format!("agent {did} not found")))?;
Ok((StatusCode::OK, Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
})))
}
/// GET /api/v1/agents/{did}/trust
@@ -133,126 +79,14 @@ pub async fn get_trust(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<TrustResponse>> {
let normalized_did = resolve_agent_did(&state, &did).await?;
let trust_score = state.db.get_trust_score(&normalized_did).await?;
let push_count = state.db.get_push_count(&normalized_did).await?;
let trust_score = state.db.get_trust_score(&did).await?;
let push_count = state.db.get_push_count(&did).await?;
let level = trust_level(trust_score);
Ok(Json(TrustResponse {
did: normalized_did,
did,
trust_score,
push_count,
level,
}))
}
/// DELETE /api/v1/agents/{did}
///
/// Self-deregistration: the holder of a DID's key marks their own agent
/// `revoked`, removing it from discovery (issue #29). Authenticated by the
/// rfc9421 signature middleware; a caller may only retire its own DID.
pub async fn deregister_agent(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(did): Path<String>,
) -> Result<StatusCode> {
// Authorize on the verified identity, and act on it too. The path DID is
// only an intent check (it must name the caller); the row we revoke is the
// authenticated DID itself, never a value derived from the untrusted path.
// Revoking `resolve_agent_did(did)` instead would let the fuzzy prefix
// resolver act on a different identity than the one just authorized.
if !caller_matches_did(&auth.0, &did) {
return Err(AppError::BadRequest(
"an agent can only deregister itself".into(),
));
}
if state.db.revoke_agent(&auth.0).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound(format!("agent {} not found", auth.0)))
}
}
#[cfg(test)]
mod tests {
use super::{agent_key_segment, caller_matches_did, normalize_agent_did, AgentResponse};
#[test]
fn caller_matches_own_did_full_form() {
assert!(caller_matches_did(
"did:key:z6MkExample",
"did:key:z6MkExample"
));
}
#[test]
fn caller_matches_own_did_short_form() {
// Authenticated full DID vs short path form, and vice versa.
assert!(caller_matches_did("did:key:z6MkExample", "z6MkExample"));
assert!(caller_matches_did("z6MkExample", "did:key:z6MkExample"));
}
#[test]
fn caller_cannot_revoke_a_different_did() {
// The core authorization property for issue #29.
assert!(!caller_matches_did(
"did:key:z6MkAttacker",
"did:key:z6MkVictim"
));
assert!(!caller_matches_did("did:key:z6MkAttacker", "z6MkVictim"));
}
#[test]
fn agent_response_surfaces_status() {
// A revoked DID's response must carry its status so callers can see it
// is retired (issue #29).
let resp = AgentResponse {
did: "did:key:orphan".to_string(),
trust_score: 0.1,
capabilities: vec!["reputation:score".to_string()],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "revoked".to_string(),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "revoked");
assert!(json.get("replaced_by").is_none());
}
#[test]
fn agent_response_active_status() {
let resp = AgentResponse {
did: "did:key:active".to_string(),
trust_score: 0.5,
capabilities: vec![],
registered_at: "2026-06-19T00:00:00Z".to_string(),
last_seen: None,
status: "active".to_string(),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "active");
}
#[test]
fn normalize_agent_did_preserves_full_did() {
let did = "did:key:z6MkExample";
assert_eq!(normalize_agent_did(did), did);
}
#[test]
fn normalize_agent_did_expands_bare_key() {
assert_eq!(normalize_agent_did("z6MkExample"), "did:key:z6MkExample");
}
#[test]
fn agent_key_segment_extracts_did_key_material() {
assert_eq!(agent_key_segment("did:key:z6MkExample"), "z6MkExample");
assert_eq!(agent_key_segment("z6MkExample"), "z6MkExample");
}
}
+8 -102
View File
@@ -1,13 +1,12 @@
//! GET /api/v1/arweave/anchors — list Arweave ref-update anchors.
use axum::{
extract::{Extension, Query, State},
extract::{Query, State},
Json,
};
use serde::Deserialize;
use crate::db::normalize_owner_key;
use crate::error::{AppError, Result};
use crate::error::Result;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
@@ -22,112 +21,19 @@ fn default_limit() -> i64 {
}
/// GET /api/v1/arweave/anchors
///
/// `?repo=<owner>/<name>` returns anchors for one repository and binds the
/// request to the canonical read-authorization path used by every other
/// repo-scoped read: the same `authorize_repo_read` helper, with the caller
/// identity attached. Missing repositories, quarantined mirrors, and signed
/// non-readers all collapse to the standard `404` (no existence oracle). The
/// unscoped listing is auth-only — the #121 contract — and does not admit
/// visibility filtering, which is the #136 stale-index class and explicitly
/// out of scope for this auth slice.
pub async fn list_anchors(
State(state): State<AppState>,
Query(q): Query<ListAnchorsQuery>,
auth: Option<Extension<crate::auth::AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// The route's `optional_signature` layer is permissive (it admits unsigned
// legacy callers) so this in-handler check is the actual gate. A signed
// non-reader still passes this check and proceeds to the authz step below.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if caller.is_none() {
return Err(AppError::Unauthorized(
"authentication required for anchor listing".into(),
));
}
// Clamp before the SQL query. A negative or non-numeric limit must not
// reach Postgres as `LIMIT -1` (which it rejects as a db error / 500) — the
// contract is the same default ceiling as the listing page itself.
let limit = q.limit.clamp(0, 200);
let anchors = if let Some(repo_slug) = q.repo.as_deref() {
// Parse the user-supplied "owner/name" through the same slug validator
// the sync path uses, so a malformed query is a 400, not a 500.
let (owner, name) = match crate::git::repo_store::validate_repo_slug(repo_slug) {
Ok(parts) => parts,
Err(e) => return Err(AppError::BadRequest(format!("invalid ?repo: {e}"))),
};
// Read gate. Returns `RepoNotFound` (→ 404) indistinguishably for
// missing repos, quarantined mirrors, and signed non-readers.
let (record, _rules) =
crate::api::authorize_repo_read(&state, owner, name, caller, "/").await?;
// Build the SQL filter from the canonical stored slug, NOT from the
// user-supplied `?repo=` string. Anchor rows are written as
// `{normalize_owner_key(owner_did)}/{name}` (the short form), so a
// request that passes authz with the full `did:key:…/name` form would
// match zero rows and return a false empty page. The gate already
// verified the repo is readable; `record.owner_did` is the canonical
// identity and `record.name` is the stored name.
let stored_slug = format!("{}/{}", normalize_owner_key(&record.owner_did), record.name);
state
.db
.list_arweave_anchors(Some(&stored_slug), limit)
.await?
} else {
// No scope → no repo-read decision. Auth-only.
state.db.list_arweave_anchors(None, limit).await?
};
let limit = q.limit.min(200);
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await
.map_err(|e| crate::error::AppError::Internal(e.into()))?;
Ok(Json(serde_json::json!({
"anchors": anchors,
"count": anchors.len(),
})))
}
#[cfg(test)]
mod closed_pool_tests {
use super::*;
use axum::http::{Request, StatusCode};
use axum::Router;
use serde_json::Value;
use sqlx::PgPool;
use tower::ServiceExt;
/// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable.
#[sqlx::test]
async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
pool.close().await;
let resp = Router::new()
.route("/api/v1/arweave/anchors", axum::routing::get(list_anchors))
.with_state(state)
.oneshot(
Request::builder()
.uri("/api/v1/arweave/anchors")
.extension(crate::auth::AuthenticatedDid("did:key:test".into()))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed-pool outage must be retryable 503, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
serde_json::json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})
);
}
}
+61 -330
View File
@@ -79,9 +79,9 @@ pub async fn create_bounty(
return Err(AppError::BadRequest("amount must be positive".into()));
}
// Read-gate: anyone who can read the repo may fund a bounty on it (this closes
// the private-repo bounty leak); ownership is not required.
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
// Verify repo exists
let _ = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let now = Utc::now().to_rfc3339();
let bounty = BountyRecord {
@@ -110,30 +110,18 @@ pub async fn create_bounty(
Ok((StatusCode::CREATED, Json(bounty)))
}
const MAX_BOUNTY_LIMIT: i64 = 200;
/// GET /api/v1/repos/{owner}/{repo}/bounties
pub async fn list_repo_bounties(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let limit = q.limit.unwrap_or(50).clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
limit,
None,
None,
)
.await?;
let bounties = state.db.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
@@ -142,92 +130,24 @@ pub async fn list_repo_bounties(
pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let raw_limit = q.limit.unwrap_or(50);
let limit = raw_limit.clamp(1, MAX_BOUNTY_LIMIT);
let bounties = state.db.list_bounties(
None,
None,
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let mut memo = std::collections::HashMap::new();
let mut allowed: Vec<BountyRecord> = Vec::new();
let page_size = (limit * 5).clamp(1, 200);
let max_scanned = 10_000i64;
let mut total_scanned: i64 = 0;
let mut cursor: Option<(String, String)> = None;
while (allowed.len() as i64) < limit {
let after = cursor.as_ref().map(|(ts, id)| (ts.as_str(), id.as_str()));
let bounties = state
.db
.list_bounties(
None,
None,
q.status.as_deref(),
page_size,
after.map(|(ts, _)| ts),
after.map(|(_, id)| id),
)
.await?;
if bounties.is_empty() {
break;
}
total_scanned += bounties.len() as i64;
if total_scanned > max_scanned {
return Err(AppError::Incomplete(
"too many rows scanned; refine your filters".into(),
));
}
let last = bounties.last().unwrap();
for b in &bounties {
if (allowed.len() as i64) >= limit {
break;
}
let key = format!("{}/{}", b.repo_owner, b.repo_name);
let admitted = if let Some(&result) = memo.get(&key) {
result
} else {
let result = crate::api::authorize_repo_read(
&state,
&b.repo_owner,
&b.repo_name,
caller,
"/",
)
.await
.is_ok();
memo.insert(key, result);
result
};
if admitted {
allowed.push(b.clone());
}
}
cursor = Some((last.created_at.clone(), last.id.clone()));
}
Ok(Json(serde_json::json!({ "bounties": allowed })))
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
/// GET /api/v1/bounties/{id}
pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
if crate::api::authorize_repo_read(&state, &bounty.repo_owner, &bounty.repo_name, caller, "/")
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(bounty))
}
@@ -238,42 +158,19 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not open",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not open", bounty.status)));
}
let now = Utc::now().to_rfc3339();
state
.db
.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now)
.await?;
state.db.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now).await?;
tracing::info!(bounty_id = %id, agent = %auth.0, "bounty claimed");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -285,35 +182,14 @@ pub async fn submit_bounty(
Path(id): Path<String>,
Json(req): Json<SubmitBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not claimed",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not claimed", bounty.status)));
}
let is_claimant = bounty
.claimant_did
.as_deref()
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
if !is_claimant {
return Err(AppError::Forbidden("only the claimant can submit".into()));
if bounty.claimant_did.as_deref() != Some(&auth.0) {
return Err(AppError::BadRequest("only the claimant can submit".into()));
}
let now = Utc::now().to_rfc3339();
@@ -321,10 +197,7 @@ pub async fn submit_bounty(
tracing::info!(bounty_id = %id, pr_id = %req.pr_id, "bounty submission");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -336,40 +209,18 @@ pub async fn approve_bounty(
Path(id): Path<String>,
Json(req): Json<ApproveBountyRequest>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not submitted",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not submitted", bounty.status)));
}
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
return Err(AppError::Forbidden(
"only the bounty creator can approve".into(),
));
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest("only the bounty creator can approve".into()));
}
let now = Utc::now().to_rfc3339();
state
.db
.approve_bounty(&id, &now, req.tx_hash.as_deref())
.await?;
state.db.approve_bounty(&id, &now, req.tx_hash.as_deref()).await?;
// Bump claimant trust score
if let Some(ref agent_did) = bounty.claimant_did {
@@ -380,10 +231,7 @@ pub async fn approve_bounty(
tracing::info!(bounty_id = %id, "bounty approved");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -394,42 +242,20 @@ pub async fn cancel_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
"can only cancel open bounties, status is {}",
bounty.status
)));
return Err(AppError::BadRequest(format!("can only cancel open bounties, status is {}", bounty.status)));
}
if !crate::api::did_matches(&auth.0, &bounty.creator_did) {
return Err(AppError::Forbidden(
"only the bounty creator can cancel".into(),
));
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest("only the bounty creator can cancel".into()));
}
state.db.cancel_bounty(&id).await?;
tracing::info!(bounty_id = %id, "bounty cancelled");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -437,44 +263,13 @@ pub async fn cancel_bounty(
/// POST /api/v1/bounties/{id}/dispute
pub async fn dispute_bounty(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let not_found = || AppError::NotFound(format!("bounty {id} not found"));
let bounty = state.db.get_bounty(&id).await?.ok_or_else(not_found)?;
if crate::api::authorize_repo_read(
&state,
&bounty.repo_owner,
&bounty.repo_name,
Some(auth.0.as_str()),
"/",
)
.await
.is_err()
{
return Err(not_found());
}
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" && bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
"can only dispute claimed/submitted bounties, status is {}",
bounty.status
)));
}
// Only the bounty creator or the current claimant may dispute (N19 — this
// endpoint had no identity check at all).
let is_creator = crate::api::did_matches(&auth.0, &bounty.creator_did);
let is_claimant = bounty
.claimant_did
.as_deref()
.is_some_and(|c| crate::api::did_matches(&auth.0, c));
if !is_creator && !is_claimant {
return Err(AppError::Forbidden(
"only the bounty creator or claimant can dispute this bounty".into(),
));
return Err(AppError::BadRequest(format!("can only dispute claimed/submitted bounties, status is {}", bounty.status)));
}
// Check if deadline exceeded
@@ -482,94 +277,33 @@ pub async fn dispute_bounty(
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
let deadline = claimed + chrono::Duration::seconds(bounty.deadline_secs);
if Utc::now() < deadline {
return Err(AppError::BadRequest(
"deadline has not been exceeded yet".into(),
));
return Err(AppError::BadRequest("deadline has not been exceeded yet".into()));
}
}
}
state.db.dispute_bounty(&id).await?;
tracing::info!(bounty_id = %id, actor = %auth.0, "bounty disputed - reopened");
tracing::info!(bounty_id = %id, "bounty disputed — reopened");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
/// all deduped repos, batch-load their visibility rules, keep only those that
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
/// per-repo authorization queries.
///
/// Both bounty stats handlers mount behind `optional_signature` (see
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
/// anonymous access. Every caller, signed or not, currently receives the
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
/// visibility can be threaded through here when that work lands.
///
/// A database error is returned to the caller, never collapsed to an empty
/// set: an empty set would render as HTTP 200 with zero counts, which a client
/// reads as confirmed absence of activity.
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
let rows = state.db.list_all_repos_deduped().await?;
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
let pairs = rows
.iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
// canonical row and its rules; a mirror-only repo counts as public, the
// same answer authorize_repo_read and /stats give. Pinned by
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
})
.map(|r| {
(
crate::db::normalize_owner_key(&r.owner_did).to_string(),
r.name.clone(),
)
})
.collect();
Ok(pairs)
}
/// GET /api/v1/bounties/stats
///
/// Aggregates are restricted to anonymously-listable repos so private-repo
/// bounty activity is not exposed (#477). The visible-repo set is resolved
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
/// passed into the filtered aggregate queries.
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
let visible = visible_repo_pairs(&state).await?;
pub async fn bounty_stats(
State(state): State<AppState>,
) -> Result<Json<BountyStatsResponse>> {
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
let claimed = state.db.count_bounties_by_status("claimed").await.unwrap_or(0);
let completed = state.db.count_bounties_by_status("completed").await.unwrap_or(0);
let open = state
.db
.count_bounties_by_status_visible("open", &visible)
.await?;
let claimed = state
.db
.count_bounties_by_status_visible("claimed", &visible)
.await?;
let completed = state
.db
.count_bounties_by_status_visible("completed", &visible)
.await?;
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
let leaderboard = leaders
.into_iter()
.map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
})
.collect();
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
let leaderboard = leaders.into_iter().map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
}).collect();
Ok(Json(BountyStatsResponse {
open,
@@ -580,14 +314,11 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
}
/// GET /api/v1/agents/{did}/bounties
///
/// Per-agent earnings restricted to anonymously-listable repos (#477).
pub async fn agent_bounty_stats(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<serde_json::Value>> {
let visible = visible_repo_pairs(&state).await?;
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
Ok(Json(serde_json::json!({
"did": did,
"completed_bounties": count,
+21 -61
View File
@@ -1,87 +1,47 @@
//! API handlers for ref certificates.
use std::collections::HashMap;
use axum::extract::{Extension, Path, Query, State};
use axum::extract::{Path, State};
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50
/// GET /api/v1/repos/{owner}/{repo}/certs
pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let limit = params
.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.map(|v| v.max(1))
.unwrap_or(50)
.min(200);
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs.iter().map(|c| serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})).collect();
// When a prefix is given (short-ID resolution from the CLI) use a
// generous limit and delegate to the prefix-matched query so the
// caller can resolve IDs regardless of how many certs exist.
let prefix = params.get("prefix").filter(|p| !p.is_empty());
let certs = if let Some(prefix) = prefix {
state
.db
.list_ref_certificates_by_prefix(&record.id, prefix, 200)
.await?
} else {
state.db.list_ref_certificates(&record.id, limit).await?
};
let certs_json: Vec<serde_json::Value> = certs
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})
})
.collect();
let count = certs_json.len();
Ok(Json(
serde_json::json!({ "certificates": certs_json, "count": count }),
))
Ok(Json(serde_json::json!({ "certificates": certs_json })))
}
/// GET /api/v1/repos/{owner}/{repo}/certs/{id}
pub async fn get_cert(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
// Verify the repo exists
let _record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let cert = state
.db
.get_ref_certificate(&id)
.await?
let cert = state.db.get_ref_certificate(&id).await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
if cert.repo_id != record.id {
return Err(AppError::NotFound(format!("certificate {id}")));
}
Ok(Json(serde_json::json!({
"id": cert.id,
"repo_id": cert.repo_id,
+14 -25
View File
@@ -1,10 +1,9 @@
//! Changelog endpoint — unified timeline of commits, merged PRs, and closed issues.
use axum::extract::{Extension, Path, Query, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::git::store;
use crate::state::AppState;
@@ -15,9 +14,7 @@ pub struct ChangelogQuery {
pub limit: usize,
}
fn default_limit() -> usize {
20
}
fn default_limit() -> usize { 20 }
/// GET /api/v1/repos/:owner/:repo/changelog[?limit=N]
///
@@ -28,36 +25,28 @@ pub async fn get_changelog(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(query): Query<ChangelogQuery>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let limit = query.limit.min(100);
// ── Commits from git log ─────────────────────────────────────────────
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let commits = store::log(&disk_path, &head_ref, limit).unwrap_or_default();
let mut events: Vec<serde_json::Value> = commits
.into_iter()
.map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
})
let mut events: Vec<serde_json::Value> = commits.into_iter().map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
})
.collect();
}).collect();
// ── Merged PRs ───────────────────────────────────────────────────────
let prs = state.db.list_prs(&record.id).await.unwrap_or_default();
-123
View File
@@ -1,123 +0,0 @@
//! Authenticated discovery + fetch for encrypted withheld blobs (Option B1).
use axum::extract::{Extension, Path, State};
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).
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 rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
.map(|(oid, cid)| serde_json::json!({ "oid": oid, "cid": cid }))
.collect();
Ok(Json(serde_json::json!({ "blobs": blobs })))
}
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blob/{oid}
/// Returns raw envelope bytes to callers who can read the repo; the envelope
/// crypto still ensures only true recipients can decrypt.
pub async fn get_encrypted_blob(
State(state): State<AppState>,
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 cid = state
.db
.encrypted_blob_cid(&record.id, &oid)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}/{oid}")))?;
let bytes = crate::ipfs_pin::cat(&state.config.ipfs_api, &cid)
.await
.map_err(|e| AppError::Git(e.to_string()))?;
Ok(bytes)
}
/// GET /api/v1/repos/{owner}/{repo}/encrypted-blobs/replicate
/// Returns [{oid, cid}] for every encrypted blob in the repo, for peer-mirror
/// replication (Option B2). Gated by repo readability, like discovery, so a
/// non-readable repo does not expose its blob index; for the intended case (a
/// public repo with withheld subtrees) the public root keeps this open to peers.
/// Recipient identities are deliberately withheld: the v2 envelopes no longer
/// carry recipient public keys, so peers must not learn the reader set either. A
/// mirror detects a re-seal by the CID changing (the OID is stable across
/// re-seals). Ciphertext metadata only, never plaintext.
pub async fn replicate_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 rows = state.db.list_all_encrypted_blobs(&record.id).await?;
let blobs: Vec<_> = rows
.into_iter()
.map(|(oid, cid)| replicate_blob_json(oid, cid))
.collect();
Ok(Json(serde_json::json!({ "blobs": blobs })))
}
/// Serialize one blob for the replication wire. Recipient identities are
/// intentionally absent so a mirror never learns the reader set.
fn replicate_blob_json(oid: String, cid: String) -> serde_json::Value {
serde_json::json!({ "oid": oid, "cid": cid })
}
#[cfg(test)]
mod tests {
use super::replicate_blob_json;
#[test]
fn replicate_blob_json_omits_recipients() {
let v = replicate_blob_json("oid1".into(), "cidA".into());
assert_eq!(v["oid"], "oid1");
assert_eq!(v["cid"], "cidA");
assert!(
v.get("recipients").is_none(),
"replication wire must not carry recipient identities"
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -56
View File
@@ -14,90 +14,51 @@ pub struct LabelRequest {
pub label: String,
}
/// Canonicalize and validate a label string. `add_label` and `remove_label`
/// must agree on the form stored in the DB: `add_label` already trims and
/// lowercases before storing, so `remove_label` has to do the same to find
/// the row. Validation is shared so neither handler can drift on charset or
/// length (#344).
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
let label = raw.trim().to_lowercase();
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = req.label.trim().to_lowercase();
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
if !label
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == ':')
{
if !label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == ':') {
return Err(AppError::BadRequest(
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
Ok(label)
}
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let added = state.db.add_label(&record.id, &label).await?;
let status = if added {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((
status,
Json(serde_json::json!({ "label": label, "added": added })),
))
let status = if added { StatusCode::CREATED } else { StatusCode::OK };
Ok((status, Json(serde_json::json!({ "label": label, "added": added }))))
}
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
pub async fn remove_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
if !removed {
return Err(AppError::NotFound(format!("label '{label}'")));
}
state.db.remove_label(&record.id, &label).await?;
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
}
/// GET /api/v1/repos/:owner/:repo/labels
///
/// Read-visibility-gated (INV-2 root listing): a public repo's labels stay
/// anonymously listable; a private repo's label names are hidden (404) from
/// anyone who cannot read it at the root.
pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let labels = state.db.list_labels(&record.id).await?;
Ok(Json(serde_json::json!({ "labels": labels })))
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-171
View File
@@ -1,171 +0,0 @@
//! Agent profile API handlers.
//!
//! - `PUT /api/v1/profile` — upsert the caller's profile (requires HTTP Signature)
//! - `GET /api/v1/agents/{did}/profile` — read any agent's profile (public)
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::auth::AuthenticatedDid;
use crate::pinata;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct SetProfileRequest {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub website: Option<String>,
pub socials: Option<SocialsInput>,
pub pin_to_ipfs: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SocialsInput {
pub twitter: Option<String>,
pub github: Option<String>,
pub farcaster: Option<String>,
pub telegram: Option<String>,
}
pub async fn set_profile(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<AuthenticatedDid>,
Json(req): Json<SetProfileRequest>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
let did = auth.0;
if let Some(ref bio) = req.bio {
if bio.len() > 280 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "bio must be 280 characters or fewer" })),
));
}
}
if let Some(ref name) = req.display_name {
if name.len() > 50 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "display_name must be 50 characters or fewer" })),
));
}
}
let socials_json = req
.socials
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_else(|_| "{}".to_string()));
let result = state
.db
.upsert_profile(
&did,
req.display_name.as_deref(),
req.bio.as_deref(),
req.avatar_url.as_deref(),
req.website.as_deref(),
socials_json.as_deref(),
)
.await;
match result {
Ok(profile) => {
let mut resp = json!({
"did": profile.did,
"display_name": profile.display_name,
"bio": profile.bio,
"avatar_url": profile.avatar_url,
"website": profile.website,
"updated_at": profile.updated_at,
});
if let Some(ref socials_str) = profile.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}
if req.pin_to_ipfs.unwrap_or(false) && !state.config.pinata_jwt.is_empty() {
let profile_json = serde_json::to_vec(&resp).unwrap_or_default();
match pinata::pin_object(
&state.http_client,
&state.config.pinata_upload_url,
&state.config.pinata_jwt,
&format!("profile-{}", did),
&profile_json,
)
.await
{
Ok(cid) if !cid.is_empty() => {
if let Err(e) = state.db.set_profile_cid(&did, &cid).await {
tracing::warn!(did = %did, err = %e, "failed to store profile CID");
}
resp["profile_cid"] = json!(cid);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(did = %did, err = %e, "IPFS pin failed — profile saved without CID");
}
}
} else if let Some(cid) = profile.profile_cid {
resp["profile_cid"] = json!(cid);
}
Ok((StatusCode::OK, Json(resp)))
}
Err(e) => {
tracing::error!(did = %did, error = %e, "failed to upsert profile");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to save profile" })),
))
}
}
}
pub async fn get_profile(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let profile = state.db.get_profile(&did).await.map_err(|e| {
tracing::error!(did = %did, error = %e, "failed to fetch profile");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to fetch profile" })),
)
})?;
match profile {
Some(p) => {
let mut resp = json!({
"did": p.did,
"display_name": p.display_name,
"bio": p.bio,
"avatar_url": p.avatar_url,
"website": p.website,
"profile_cid": p.profile_cid,
"created_at": p.created_at,
"updated_at": p.updated_at,
});
if let Some(ref socials_str) = p.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}
Ok(Json(resp))
}
None => Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "profile not found" })),
)),
}
}
+16 -28
View File
@@ -17,16 +17,14 @@ 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?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Only the repo owner can protect branches (DID-safe match, shared idiom).
// Only the repo owner can protect branches
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
return Err(AppError::Forbidden(
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can protect branches".into(),
));
}
@@ -35,14 +33,11 @@ pub async fn protect_branch(
tracing::info!(repo = %repo, branch = %branch, caller = %caller, "branch protected");
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
})),
))
Ok((StatusCode::CREATED, Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
}))))
}
/// DELETE /api/v1/repos/:owner/:repo/branches/:branch/protect
@@ -51,15 +46,13 @@ 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?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
if !crate::api::did_matches(caller, &record.owner_did) {
return Err(AppError::Forbidden(
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can unprotect branches".into(),
));
}
@@ -79,14 +72,9 @@ pub async fn unprotect_branch(
pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// Read-visibility-gated (INV-2 root listing): a public repo's protected
// branches stay anonymously listable; a private repo's branch names are
// hidden (404) from anyone who cannot read it at the root.
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let branches = state.db.list_protected_branches(&record.id).await?;
+38 -257
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::{PrComment, PrReview, PullRequest};
use crate::db::{PullRequest, PrComment, PrReview};
use crate::error::{AppError, Result};
use crate::git::store;
use crate::state::AppState;
@@ -40,27 +40,11 @@ pub async fn create_pr(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreatePrRequest>,
) -> Result<(StatusCode, Json<PullRequest>)> {
// Authorize the caller as a reader before accepting a PR: a non-reader must
// not be able to open a PR (and fire its webhooks) against a private repo
// they cannot read. Mirrors create_review / create_comment / create_bounty.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let author_did = auth.0;
let target_branch = req
.target_branch
.unwrap_or_else(|| record.default_branch.clone());
// Validate both refs before they are stored, since both are later
// interpolated into git argv (git diff / worktree add / merge). Validate the
// RESOLVED target, not just a caller-supplied one: create_repo also gates
// default_branch, but validating here as well means a PR can never feed the
// git sink an unchecked ref even if a default was poisoned by an older row
// or a future path that skips create_repo's gate.
crate::api::validate_git_ref_with_git(&state.git_bin, &req.source_branch)
.map_err(crate::api::map_git_ref_validation_error)?;
crate::api::validate_git_ref_with_git(&state.git_bin, &target_branch)
.map_err(crate::api::map_git_ref_validation_error)?;
let target_branch = req.target_branch.unwrap_or_else(|| record.default_branch.clone());
let number = state.db.next_pr_number(&record.id).await?;
let now = Utc::now().to_rfc3339();
@@ -84,11 +68,7 @@ pub async fn create_pr(
// Bump trust score for the PR author — increment current score by 0.05
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only open PRs)
let current = state
.db
.get_trust_score(&pr.author_did)
.await
.unwrap_or(0.05);
let current = state.db.get_trust_score(&pr.author_did).await.unwrap_or(0.05);
let new_score = (current + 0.05).min(1.0);
let _ = state.db.update_trust_score(&pr.author_did, new_score).await;
@@ -112,32 +92,23 @@ pub async fn create_pr(
pub async fn list_prs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let prs = state.db.list_prs(&record.id).await?;
Ok(Json(
serde_json::json!({ "pulls": prs, "count": prs.len() }),
))
Ok(Json(serde_json::json!({ "pulls": prs, "count": prs.len() })))
}
/// GET /api/v1/repos/:owner/:repo/pulls/:number
pub async fn get_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<PullRequest>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
Ok(Json(pr))
@@ -147,42 +118,15 @@ pub async fn get_pr(
pub async fn get_pr_diff(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
// Withhold the entire diff if it touches a path the caller cannot read, so a
// PR diff cannot leak private-subtree content of an otherwise-public repo.
let touched = store::branch_diff_names(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
for p in &touched {
let gate = format!("/{}", p.trim_start_matches('/'));
if crate::visibility::visibility_check(
&rules,
record.is_public,
&record.owner_did,
caller,
&gate,
) == crate::visibility::Decision::Deny
{
return Err(AppError::NotFound(format!("PR #{number} not found")));
}
}
let diff = store::branch_diff(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
@@ -199,47 +143,24 @@ 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?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// 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
// non-owner cannot merge to any branch, protected or not).
crate::api::require_repo_owner(&record, &auth.0)?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
if pr.status != "open" {
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
.map_err(|e| crate::api::repos::acquire_write_app_error(&e, &name))?;
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
&pr.source_branch,
&merger_did,
&pr.title,
);
let merge_result = store::merge_branch(&disk_path, &pr.target_branch, &pr.source_branch, &merger_did, &pr.title);
// Always release the advisory lock — even on error; upload to Tigris only on success.
// Short-circuit on a refused publish before the PR is marked merged and
// before the webhook fires. Both are irreversible announcements of a merge
// commit that only exists on this node's disk.
guard.release(merge_result.is_ok()).await.into_result()?;
// Always release the advisory lock — even on error
guard.release().await;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -271,30 +192,14 @@ pub async fn merge_pr(
/// POST /api/v1/repos/:owner/:repo/pulls/:number/close
pub async fn close_pr(
State(state): State<AppState>,
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?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
// Owner OR author may close (forge norm).
let is_owner = crate::api::require_repo_owner(&record, &auth.0).is_ok();
let is_author = crate::api::did_matches(&auth.0, &pr.author_did);
if !is_owner && !is_author {
return Err(AppError::Forbidden(
"only the repo owner or the PR author can close this PR".into(),
));
}
state.db.close_pr(&pr.id).await?;
webhooks::fire_event(
@@ -319,21 +224,15 @@ pub async fn create_review(
Path((owner, name, number)): Path<(String, String, i64)>,
Json(req): Json<CreateReviewRequest>,
) -> Result<(StatusCode, Json<PrReview>)> {
// Read-gate: a reviewer must be able to read the repo, but need not own it.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let valid_statuses = ["approved", "changes_requested", "comment"];
if !valid_statuses.contains(&req.status.as_str()) {
return Err(AppError::BadRequest(
"status must be approved, changes_requested, or comment".into(),
));
return Err(AppError::BadRequest("status must be approved, changes_requested, or comment".into()));
}
let review = PrReview {
@@ -367,16 +266,11 @@ pub async fn create_review(
pub async fn list_reviews(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let reviews = state.db.list_pr_reviews(&pr.id).await?;
@@ -391,19 +285,13 @@ pub async fn create_comment(
Json(req): Json<CreateCommentRequest>,
) -> Result<(StatusCode, Json<PrComment>)> {
if req.body.trim().is_empty() {
return Err(AppError::BadRequest(
"comment body must not be empty".into(),
));
return Err(AppError::BadRequest("comment body must not be empty".into()));
}
// Read-gate: a commenter must be able to read the repo, but need not own it.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(auth.0.as_str()), "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comment = PrComment {
@@ -423,120 +311,13 @@ pub async fn create_comment(
pub async fn list_comments(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comments = state.db.list_pr_comments(&pr.id).await?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
/// #173 F1 follow-up: merge holds no admission permit, so an exhausted write-lock
/// pool must shed 503 + Retry-After.
#[cfg(test)]
mod lock_pool_shed_tests {
use super::*;
use axum::response::IntoResponse;
use sqlx::PgPool;
fn seed_repo(owner_did: &str, name: &str) -> crate::db::RepoRecord {
let now = Utc::now();
crate::db::RepoRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
owner_did: owner_did.to_string(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: now,
updated_at: now,
disk_path: format!("/tmp/{name}"),
forked_from: None,
machine_id: None,
}
}
#[sqlx::test]
async fn merge_pr_lock_pool_exhaustion_sheds_503_not_500(pool: PgPool) {
let owner = "did:key:zMERGELOCKPOOLOWNERAAAAAAAAAAAAAAAAAAAAA";
let mut state = crate::test_support::test_state(pool.clone()).await;
state.repo_store = crate::git::repo_store::RepoStore::new(
std::path::PathBuf::from("/tmp/gitlawb-pulls-lockpool"),
None,
crate::git::repo_store::build_lock_pool(&pool, 1, std::time::Duration::from_secs(1)),
std::time::Duration::from_secs(300),
);
let repo = seed_repo(owner, "lp-merge");
let repo_id = repo.id.clone();
state.db.create_repo(&repo).await.expect("seed repo");
let now = Utc::now().to_rfc3339();
state
.db
.create_pr(&PullRequest {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.clone(),
number: 1,
title: "lp".to_string(),
body: None,
author_did: owner.to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
status: "open".to_string(),
merged_by_did: None,
merged_at: None,
created_at: now.clone(),
updated_at: now,
})
.await
.expect("seed open PR");
let held = state
.repo_store
.acquire_write(owner, "other-repo")
.await
.expect("the first write takes the only lock-pool connection");
let shed = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
let err = shed.expect_err("an exhausted lock pool must fail the call");
let resp = err.into_response();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"merge_pr: an exhausted write-lock pool must shed 503, not a 500 git error"
);
assert_eq!(
resp.headers()
.get("retry-after")
.map(|v| v.to_str().unwrap()),
Some("1"),
"merge_pr: a capacity shed must tell the client when to retry"
);
let _ = held.release(false).await;
let admitted = merge_pr(
State(state.clone()),
Extension(AuthenticatedDid(owner.to_string())),
Path((owner.to_string(), "lp-merge".to_string(), 1)),
)
.await;
assert!(
!matches!(admitted, Err(AppError::Overloaded(_))),
"with the lock pool free, merge_pr must not be shed as capacity; got {:?}",
admitted.err()
);
}
}
+16 -44
View File
@@ -1,8 +1,8 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use gitlawb_core::did::Did;
use gitlawb_core::ucan::Ucan;
@@ -35,66 +35,38 @@ pub struct RegisterResponse {
/// Accepts only requests with a valid HTTP Signature (enforced by middleware).
pub async fn register(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<crate::auth::AuthenticatedDid>,
headers: axum::http::HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>)> {
// iCaptcha gate (inert unless ICAPTCHA_MODE is set). Verify up front; spend
// the proof only once the request is admissible, just before the write, so a
// rejected request never burns a valid proof.
let proof = crate::icaptcha::verify_request(&headers, &auth.0)?;
// Parse and validate the DID
let agent_did: Did = req
.did
.parse()
let agent_did: Did = req.did.parse()
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
// Bind registration to the authenticated signer: an agent may only register
// itself, not create or refresh a trust row for a DID it does not control.
if !crate::api::did_matches(&auth.0, agent_did.as_str()) {
return Err(AppError::Forbidden(
"did must be the authenticated signer".into(),
));
}
// Request is admissible — spend the proof now, immediately before the write.
proof.consume(&state.db).await?;
// Store the agent in the local index
state
.db
.register_agent(agent_did.as_str(), &req.capabilities)
.await?;
state.db.register_agent(agent_did.as_str(), &req.capabilities).await?;
// Grant a small baseline trust score on first registration (verified via HTTP Signature).
// Score grows further with pushes, PRs, and issue activity.
let initial_trust = 0.05;
let _ = state
.db
.update_trust_score(agent_did.as_str(), initial_trust)
.await;
let _ = state.db.update_trust_score(agent_did.as_str(), initial_trust).await;
// Issue a bootstrap UCAN from the node's identity
let ucan = Ucan::bootstrap(&state.node_keypair, agent_did.clone())
.map_err(|e| AppError::Internal(e.into()))?;
let exp = Utc::now() + chrono::Duration::days(30);
let ucan_encoded = ucan.encode().map_err(|e| AppError::Internal(e.into()))?;
let ucan_encoded = ucan.encode()
.map_err(|e| AppError::Internal(e.into()))?;
tracing::info!(did = %agent_did, "registered new agent");
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
}),
))
Ok((StatusCode::CREATED, Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
})))
}
-203
View File
@@ -1,203 +0,0 @@
//! Repo replica registration API.
//!
//! Lets a node tell the origin "I'm hosting a replica of your repo." The
//! origin records the (replica DID, replica URL) pair and exposes the list
//! publicly so anyone can see how many nodes are mirroring a given repo.
//!
//! Endpoints:
//! - `PUT /api/v1/repos/:owner/:repo/replicas` (auth) register
//! - `DELETE /api/v1/repos/:owner/:repo/replicas` (auth) unregister
//! - `GET /api/v1/repos/:owner/:repo/replicas` (public) list
//!
//! Auth model: the caller's DID (verified via HTTP Signatures) is the
//! replica's identity. There's no separate "claim this URL" check — replicas
//! self-report their public URL. Operators viewing the list should treat
//! replica URLs as advisory until they actually reach out and verify.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct RegisterReplicaRequest {
/// Publicly reachable URL of the replica node (e.g. `https://my-node.example.com`).
pub url: String,
}
/// PUT /api/v1/repos/:owner/:repo/replicas
/// Idempotent — first registration returns 201, subsequent ones update the URL and return 200.
pub async fn register_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RegisterReplicaRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
validate_replica_url(&req.url)?;
// The mutation response carries repo metadata (name, replica count), so
// registration applies the same read-visibility decision as the listing:
// a caller who may not read the repo gets the same 404 as a missing one.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let replica_did = &auth.0;
// Don't let an owner register themselves as a replica of their own repo
// (did_matches handles the full vs bare did:key owner form).
if crate::api::did_matches(replica_did, &record.owner_did) {
return Err(AppError::BadRequest(
"the repo owner is not a replica of their own repo".into(),
));
}
let inserted = state
.db
.register_replica(&record.id, replica_did, &req.url)
.await?;
let count = state.db.count_replicas(&record.id).await?;
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
tracing::info!(
repo = %repo,
replica = %replica_did,
url = %req.url,
"replica registered"
);
Ok((
status,
Json(serde_json::json!({
"status": "registered",
"repo": format!("{owner}/{repo}"),
"replica_did": replica_did,
"replica_url": req.url,
"replica_count": count,
})),
))
}
/// DELETE /api/v1/repos/:owner/:repo/replicas
/// Idempotent — no error if the caller wasn't registered.
pub async fn unregister_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
// Same gate as register_replica: removal mutates repo-scoped metadata and
// its response discloses the replica count.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let replica_did = &auth.0;
state.db.unregister_replica(&record.id, replica_did).await?;
let count = state.db.count_replicas(&record.id).await?;
tracing::info!(repo = %repo, replica = %replica_did, "replica unregistered");
Ok(Json(serde_json::json!({
"status": "unregistered",
"repo": format!("{owner}/{repo}"),
"replica_count": count,
})))
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Read-visibility-gated: a PUBLIC repo's replica list stays anonymously
/// listable (mirror-discovery), but a PRIVATE repo's replica URLs are hidden
/// (404) from anyone who cannot read the repo at its root.
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let replicas = state.db.list_replicas(&record.id).await?;
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"replica_count": replicas.len(),
"replicas": replicas,
})))
}
/// Basic URL hygiene — must be http(s), parseable, length-bounded.
fn validate_replica_url(url: &str) -> Result<()> {
if url.is_empty() {
return Err(AppError::BadRequest("replica url is empty".into()));
}
if url.len() > 512 {
return Err(AppError::BadRequest("replica url exceeds 512 chars".into()));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(AppError::BadRequest(
"replica url must start with http:// or https://".into(),
));
}
// No spaces / control chars / newlines.
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(AppError::BadRequest(
"replica url contains whitespace or control characters".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_accepts_normal_https() {
validate_replica_url("https://node.example.com").unwrap();
validate_replica_url("https://my-node.example.com:7545").unwrap();
validate_replica_url("http://localhost:7545").unwrap();
}
#[test]
fn url_rejects_empty() {
assert!(validate_replica_url("").is_err());
}
#[test]
fn url_rejects_non_http_scheme() {
for bad in [
"ftp://host",
"file:///etc/passwd",
"javascript:alert(1)",
"/path",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_whitespace_and_control() {
for bad in [
"https://host .com",
"https://host\n.com",
"https://host\t.com",
"https://host\0evil.com",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_overlong() {
let long = format!("https://{}.com", "a".repeat(600));
assert!(validate_replica_url(&long).is_err());
}
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
//! DID resolution — look up a node by its Twigpine DID.
//! DID resolution — look up a node by its gitlawb DID.
//!
//! First checks the local peers table (HTTP-layer peer discovery),
//! then queries the Kademlia DHT via the libp2p swarm.
@@ -14,7 +14,7 @@ use crate::state::AppState;
/// GET /api/v1/resolve/{did}
///
/// Resolves a Twigpine DID to an HTTP URL and p2p multiaddr.
/// Resolves a gitlawb DID to an HTTP URL and p2p multiaddr.
/// Checks local peer cache first, then falls back to Kademlia DHT lookup.
pub async fn resolve_did(
State(state): State<AppState>,
+15 -24
View File
@@ -2,14 +2,14 @@
//!
//! Any authenticated agent can star or unstar a repo.
//! One agent = one star per repo (enforced by UNIQUE constraint in repo_stars).
//! Star count is gated on repo read access.
//! Star count is public — no auth required on GET.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use crate::auth::AuthenticatedDid;
use crate::error::Result;
use crate::error::{AppError, Result};
use crate::state::AppState;
/// PUT /api/v1/repos/:owner/:repo/star
@@ -19,29 +19,22 @@ pub async fn star_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
let inserted = state.db.star_repo(&record.id, caller).await?;
let count = state.db.count_stars(&record.id).await?;
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
let status = if inserted { StatusCode::CREATED } else { StatusCode::OK };
tracing::info!(repo = %repo, caller = %caller, "repo starred");
Ok((
status,
Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
})),
))
Ok((status, Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
}))))
}
/// DELETE /api/v1/repos/:owner/:repo/star
@@ -51,8 +44,8 @@ pub async fn unstar_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
state.db.unstar_repo(&record.id, caller).await?;
@@ -68,15 +61,13 @@ pub async fn unstar_repo(
}
/// GET /api/v1/repos/:owner/:repo/star
/// Returns star count for callers who can read the repo.
/// Returns star count — unauthenticated.
pub async fn get_star_status(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let count = state.db.count_stars(&record.id).await?;
+19 -85
View File
@@ -9,7 +9,7 @@
//! POST /api/v1/tasks/{id}/fail — fail task
use axum::{
extract::{Extension, Path, Query, State},
extract::{Path, Query, State},
http::StatusCode,
Json,
};
@@ -18,18 +18,9 @@ use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::AgentTask;
use crate::state::{AppState, TaskEventBroadcast};
/// 403 in this module's error shape (`(StatusCode, Json<Value>)`, not `AppError`).
fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": "forbidden", "message": msg })),
)
}
// ── Request / response types ──────────────────────────────────────────────────
#[derive(Deserialize)]
@@ -64,11 +55,13 @@ pub struct ClaimTaskBody {
#[derive(Deserialize)]
pub struct CompleteTaskBody {
pub result: Option<String>,
pub by_did: Option<String>,
}
#[derive(Deserialize)]
pub struct FailTaskBody {
pub reason: Option<String>,
pub by_did: Option<String>,
}
fn task_to_json(t: &AgentTask) -> Value {
@@ -94,20 +87,15 @@ fn task_to_json(t: &AgentTask) -> Value {
/// POST /api/v1/tasks
pub async fn create_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Json(body): Json<CreateTaskBody>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
// Bind the delegator to the authenticated signer (N13).
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
return Err(forbidden("delegator_did must be the authenticated signer"));
}
let now = Utc::now().to_rfc3339();
let task = AgentTask {
id: Uuid::new_v4().to_string(),
repo_id: body.repo_id,
kind: body.kind,
status: "pending".to_string(),
delegator_did: auth.0,
delegator_did: body.delegator_did,
assignee_did: body.assignee_did,
capability: body.capability,
ucan_token: body.ucan_token,
@@ -166,25 +154,24 @@ pub async fn get_task(
/// POST /api/v1/tasks/{id}/claim
pub async fn claim_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<ClaimTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Bind the assignee to the authenticated signer (N13).
if !crate::api::did_matches(&auth.0, &body.assignee_did) {
return Err(forbidden("assignee_did must be the authenticated signer"));
}
let task = state.db.claim_task(&id, &auth.0).await.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let task = state
.db
.claim_task(&id, &body.assignee_did)
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
})?;
let _ = state.task_event_tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
new_status: "claimed".to_string(),
by_did: auth.0,
by_did: body.assignee_did,
at: Utc::now().to_rfc3339(),
});
Ok(Json(task_to_json(&task)))
@@ -193,44 +180,17 @@ pub async fn claim_task(
/// POST /api/v1/tasks/{id}/complete
pub async fn complete_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<CompleteTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Authorize the actor, not just bind their identity: the N13 signer-binding
// proved the caller was whoever they claimed, but never that they were the
// task's assignee. Load the task and require the caller to be its assignee;
// finish_task then transitions only a claimed task.
let existing = state
.db
.get_task(&id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "task not found" })),
)
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
) {
return Err(forbidden("only the task assignee can complete it"));
}
let by_did = auth.0;
let by_did = body.by_did.unwrap_or_default();
let task = state
.db
.finish_task(&id, "completed", body.result.as_deref())
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": e.to_string() })),
)
})?;
@@ -247,36 +207,10 @@ pub async fn complete_task(
/// POST /api/v1/tasks/{id}/fail
pub async fn fail_task(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
Json(body): Json<FailTaskBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Authorize the actor, not just bind their identity (see complete_task): only
// the task's assignee may fail it, and finish_task transitions only a claimed
// task.
let existing = state
.db
.get_task(&id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "task not found" })),
)
})?;
if !crate::api::did_matches(
&auth.0,
existing.assignee_did.as_deref().unwrap_or_default(),
) {
return Err(forbidden("only the task assignee can fail it"));
}
let by_did = auth.0;
let by_did = body.by_did.unwrap_or_default();
let reason = body.reason.unwrap_or_default();
let task = state
.db
@@ -284,7 +218,7 @@ pub async fn fail_task(
.await
.map_err(|e| {
(
StatusCode::CONFLICT,
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": e.to_string() })),
)
})?;
-275
View File
@@ -1,275 +0,0 @@
//! Path-scoped visibility management API. Owner-only, mirrors `api/protect.rs`.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::db::VisibilityMode;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct SetVisibilityRequest {
pub path_glob: String,
/// "a" or "b"; defaults to "b" if absent or unrecognized.
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub reader_dids: Vec<String>,
}
#[derive(Deserialize)]
pub struct RemoveVisibilityRequest {
pub path_glob: String,
}
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
// DID-safe owner match (collapses did:key full vs bare on both sides, never
// across methods), shared with require_repo_owner — not a trailing-segment
// compare that only normalized the owner side.
if crate::api::did_matches(caller, &record.owner_did) {
Ok(())
} else {
Err(AppError::Forbidden(
"only the repo owner can manage visibility".into(),
))
}
}
/// Reject malformed globs before they reach the store, where they would
/// silently misconfigure access (an empty glob behaves like "/", and a glob
/// without a leading "/" never matches a real repo path). The accepted forms
/// match what `visibility_check` understands: "/", "/prefix", or "/prefix/**".
fn validate_path_glob(path_glob: &str) -> Result<()> {
if !path_glob.starts_with('/') {
return Err(AppError::BadRequest("path_glob must start with '/'".into()));
}
if path_glob == "/**" {
return Err(AppError::BadRequest(
"use '/' for whole-repo scope, not '/**'".into(),
));
}
if path_glob != "/" && path_glob.ends_with('/') {
return Err(AppError::BadRequest(
"path_glob must not end with '/'".into(),
));
}
// Reject empty path segments. `glob_prefix` collapses "//**" to "/" (whole
// repo), so without this an operator could bypass the "/**" guard above and
// grant repo-wide read with "//**", or set a dead rule like "//secret/**"
// whose prefix never matches a real single-slash path and silently leaves
// the subtree readable.
if path_glob.contains("//") {
return Err(AppError::BadRequest(
"path_glob must not contain empty path segments".into(),
));
}
// Strip the one permitted trailing "/**" before checking for stray
// wildcards, otherwise a "*" elsewhere in the prefix (e.g. "/a*b/**" or
// "/a/**/**") slips through because the string still ends with "/**".
let prefix = path_glob.strip_suffix("/**").unwrap_or(path_glob);
if prefix.contains('*') {
return Err(AppError::BadRequest(
"the only supported wildcard is a single trailing '/**'".into(),
));
}
Ok(())
}
/// PUT /api/v1/repos/{owner}/{repo}/visibility
pub async fn set_visibility(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
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}")))?;
require_owner(&record, &auth.0)?;
validate_path_glob(&req.path_glob)?;
let mode = match req.mode.as_deref() {
Some("a") => VisibilityMode::A,
_ => VisibilityMode::B,
};
// Mode A hides existence and is only coherent for the whole repo; a subtree
// cannot hide its existence without rewriting git history (see spec).
if mode == VisibilityMode::A && req.path_glob != "/" {
return Err(AppError::BadRequest(
"mode 'a' (hide) is only allowed for the whole repo (path_glob '/'); use mode 'b' for subtrees".into(),
));
}
// An empty reader_dids list is valid and intentional: the owner is always
// allowed by visibility_check, so a "/" rule with no readers is exactly the
// whole-repo "private to owner only" case.
state
.db
.set_visibility_rule(&record.id, &req.path_glob, mode, &req.reader_dids, &auth.0)
.await?;
let subtree_warning = req.path_glob != "/";
tracing::info!(
repo = %repo, caller = %auth.0, path_glob = %req.path_glob, mode = %mode.as_str(),
subtree_pending = subtree_warning,
"visibility rule set"
);
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"status": "set",
"repo": format!("{owner}/{repo}"),
"path_glob": req.path_glob,
"mode": mode.as_str(),
"reader_dids": req.reader_dids,
"subtree_clone_enforcement_pending": subtree_warning,
})),
))
}
/// DELETE /api/v1/repos/{owner}/{repo}/visibility
pub async fn remove_visibility(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
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}")))?;
require_owner(&record, &auth.0)?;
state
.db
.remove_visibility_rule(&record.id, &req.path_glob)
.await?;
tracing::info!(
repo = %repo, caller = %auth.0, path_glob = %req.path_glob,
"visibility rule removed"
);
Ok(Json(serde_json::json!({
"status": "removed",
"repo": format!("{owner}/{repo}"),
"path_glob": req.path_glob,
})))
}
/// GET /api/v1/repos/{owner}/{repo}/visibility
pub async fn list_visibility(
State(state): State<AppState>,
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}")))?;
require_owner(&record, &auth.0)?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let rules_json: Vec<_> = rules
.into_iter()
.map(|r| {
serde_json::json!({
"path_glob": r.path_glob,
"mode": r.mode.as_str(),
"reader_dids": r.reader_dids,
"created_by": r.created_by,
})
})
.collect();
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"count": rules_json.len(),
"rules": rules_json,
})))
}
/// GET /api/v1/repos/{owner}/{repo}/withheld-paths
///
/// Returns the path globs the (optionally authenticated) caller is denied
/// (`withheld`) plus any more-specific globs that are allowed underneath a
/// 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.
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 withheld =
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
let reinclude =
crate::visibility::reincluded_globs(&rules, record.is_public, &record.owner_did, caller);
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"withheld": withheld,
"reinclude": reinclude,
})))
}
#[cfg(test)]
mod tests {
use super::validate_path_glob;
#[test]
fn accepts_supported_globs() {
for g in ["/", "/secret", "/secret/**", "/a/b/c", "/a/b/**"] {
assert!(validate_path_glob(g).is_ok(), "{g} should be valid");
}
}
#[test]
fn rejects_malformed_globs() {
// empty, no leading slash, whole-repo via "/**", trailing slash,
// non-trailing wildcards, double wildcards, and empty path segments
// (which would collapse to whole-repo or a dead rule) are all rejected.
for g in [
"",
"secret/**",
"/**",
"/**/**",
"/secret/",
"/a*b",
"/*/x",
"/a*b/**",
"/a/**/**",
"//**",
"//secret/**",
] {
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
}
}
}
+16 -153
View File
@@ -1,8 +1,8 @@
//! Webhook CRUD API.
//!
//! POST /api/v1/repos/:owner/:repo/hooks — create (owner only)
//! GET /api/v1/repos/:owner/:repo/hooks — list (owner only; auth required)
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (owner only)
//! POST /api/v1/repos/:owner/:repo/hooks — create (auth required)
//! GET /api/v1/repos/:owner/:repo/hooks — list
//! DELETE /api/v1/repos/:owner/:repo/hooks/:id — delete (auth required)
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
@@ -33,23 +33,12 @@ 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?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Gate the target through the same hardened public-host validator the peer
// announce path uses, so an authenticated owner cannot register a webhook
// that makes the node POST to loopback/private/link-local/metadata
// endpoints (SSRF). Delivery runs on the shared no-redirect client
// (main.rs), which closes the 3xx-to-internal bounce.
if !crate::api::peers::is_public_http_url(&req.url) {
return Err(AppError::BadRequest(format!(
"webhook URL {}",
crate::api::peers::PUBLIC_HTTP_URL_REQUIREMENT
)));
// Validate URL is http/https
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(AppError::BadRequest("webhook URL must be http:// or https://".into()));
}
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
@@ -74,62 +63,30 @@ pub async fn create_webhook(
pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
auth: Option<Extension<AuthenticatedDid>>,
) -> Result<Json<serde_json::Value>> {
// This route sits on `optional_signature`, so the DID is optional. Webhook
// callback URLs are owner-secret config and there is no anonymous form, so a
// headerless caller is rejected before any lookup (401, which fires for an
// existing-private and an absent repo alike, so it leaks no existence).
let Some(Extension(AuthenticatedDid(caller))) = auth else {
return Err(AppError::Unauthorized(
"listing webhooks requires authentication".into(),
));
};
// Read-visibility first, then owner. authorize_repo_read returns 404 on a
// visibility deny, so a non-reader of a private repo cannot tell it exists
// (uniform with the sibling read surfaces); require_repo_owner then yields
// 403 only for a non-owner of a public/readable repo, where existence is not
// secret.
let (record, _rules) =
crate::api::authorize_repo_read(&state, &owner, &name, Some(&caller), "/").await?;
crate::api::require_repo_owner(&record, &caller)?;
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
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();
let redacted: Vec<_> = hooks.into_iter().map(|mut h| {
if h.secret.is_some() { h.secret = Some("***".into()); }
h
}).collect();
Ok(Json(
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
))
Ok(Json(serde_json::json!({ "webhooks": redacted, "count": redacted.len() })))
}
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
pub async fn delete_webhook(
State(state): State<AppState>,
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?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
// Verify the webhook belongs to this repo
let hook = state
.db
.get_webhook(&id)
.await?
let hook = state.db.get_webhook(&id).await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id} not found")))?;
if hook.repo_id != record.id {
@@ -139,97 +96,3 @@ pub async fn delete_webhook(
state.db.delete_webhook(&id).await?;
Ok(Json(serde_json::json!({ "deleted": true, "id": id })))
}
#[cfg(test)]
mod tests {
use axum::extract::{Extension, Path, State};
use axum::Json;
use chrono::Utc;
use gitlawb_core::identity::Keypair;
use sqlx::PgPool;
use uuid::Uuid;
use super::{create_webhook, CreateWebhookRequest};
use crate::api::peers::{is_public_http_url, PUBLIC_HTTP_URL_REQUIREMENT};
use crate::auth::AuthenticatedDid;
use crate::db::RepoRecord;
use crate::error::AppError;
use crate::test_support::test_state;
// 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
// old scheme-only check, independent of the validator's own peer tests.
#[test]
fn webhook_url_gate_rejects_ssrf_targets() {
for bad in [
"http://127.0.0.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/",
"http://node.localhost/",
"http://10.0.0.5/",
"http://[::1]/",
// IPv6 transition encodings smuggling loopback v4 (6to4 / NAT64).
"http://[2002:7f00:1::]/",
"http://[64:ff9b::7f00:1]/",
"ftp://example.com/",
"not-a-url",
] {
assert!(!is_public_http_url(bad), "{bad:?} must be rejected");
}
}
#[test]
fn webhook_url_gate_allows_public_targets() {
assert!(is_public_http_url("https://hooks.example.com/gitlawb"));
assert!(is_public_http_url("http://203.0.113.10:7545/"));
}
#[sqlx::test]
async fn webhook_localhost_rejection_uses_shared_public_url_contract(pool: PgPool) {
let state = test_state(pool).await;
let owner = Keypair::generate().did().to_string();
let repo_id = Uuid::new_v4().to_string();
state
.db
.create_repo(&RepoRecord {
id: repo_id.clone(),
name: "webhook-contract".to_string(),
owner_did: owner.clone(),
description: None,
is_public: true,
default_branch: "main".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: "/tmp/webhook-contract.git".to_string(),
forked_from: None,
machine_id: None,
})
.await
.unwrap();
let result = create_webhook(
State(state.clone()),
Extension(AuthenticatedDid(owner.clone())),
Path((owner, "webhook-contract".to_string())),
Json(CreateWebhookRequest {
url: "https://node.localhost/hook".to_string(),
secret: None,
events: None,
}),
)
.await;
match result {
Err(AppError::BadRequest(message)) => assert_eq!(
message,
format!("webhook URL {PUBLIC_HTTP_URL_REQUIREMENT}")
),
Err(error) => panic!("unexpected webhook rejection: {error}"),
Ok(_) => panic!("a .localhost webhook must be rejected"),
}
assert!(
state.db.list_webhooks(&repo_id).await.unwrap().is_empty(),
"a rejected .localhost webhook must leave no row behind"
);
}
}
+8 -226
View File
@@ -20,6 +20,7 @@
use anyhow::Result;
use serde_json::json;
/// Data describing a ref-update event to be anchored.
#[derive(Debug, Clone)]
pub struct RefAnchor {
@@ -103,108 +104,6 @@ pub async fn anchor_ref_update(
Ok(tx_id)
}
/// A per-push manifest of the blobs encrypted this push (Option B3). The
/// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON
/// body so the discovery index survives total node loss. Recipient identities are
/// never part of the manifest.
pub struct EncryptedManifest<'a> {
pub repo: &'a str,
pub owner_did: &'a str,
pub node_did: &'a str,
pub timestamp: &'a str,
pub blobs: &'a [(String, String)],
}
/// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest
/// JSON body is the payload (not a CID pointer to IPFS), so the index is
/// permanent and self-contained. Recipient identities are deliberately omitted:
/// the anchor is permanent and public, and the v2 envelopes no longer expose
/// recipients, so the reader set must not be written to Arweave either.
///
/// Returns the Irys/Arweave transaction ID, or `Ok("")` when `irys_url` is empty
/// (anchoring disabled) or there are no blobs to anchor.
pub async fn anchor_encrypted_manifest(
client: &reqwest::Client,
irys_url: &str,
manifest: &EncryptedManifest<'_>,
) -> Result<String> {
if irys_url.is_empty() || manifest.blobs.is_empty() {
return Ok(String::new());
}
let blobs_json: Vec<serde_json::Value> = manifest
.blobs
.iter()
.map(|(oid, cid)| manifest_blob_json(oid, cid))
.collect();
let payload = json!({
"schema": "gitlawb/encrypted-manifest/v1",
"repo": manifest.repo,
"owner_did": manifest.owner_did,
"node_did": manifest.node_did,
"timestamp": manifest.timestamp,
"blobs": blobs_json,
});
let body = serde_json::to_vec(&payload)?;
let url = format!("{}/upload", irys_url.trim_end_matches('/'));
let resp = client
.post(&url)
.header("Content-Type", "application/json")
.header("x-irys-tags", build_manifest_tags_header(manifest))
.body(body)
.send()
.await
.map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Irys returned {status}: {body}"));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?;
let tx_id = json["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))?
.to_string();
tracing::info!(
repo = %manifest.repo,
tx_id = %tx_id,
blobs = manifest.blobs.len(),
"anchored encrypted manifest to Arweave"
);
Ok(tx_id)
}
/// Serialize one blob for the Arweave manifest. Recipient identities are
/// intentionally absent so the permanent public anchor never records who can
/// read a blob.
fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value {
json!({ "oid": oid, "cid": cid })
}
/// Build the Irys tag header for an encrypted-blob manifest. `Repo` and `Schema`
/// are the tags the `gl` recovery query filters on.
fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String {
[
"App-Name:gitlawb".to_string(),
"Schema:gitlawb/encrypted-manifest/v1".to_string(),
format!("Repo:{}", sanitize_tag(manifest.repo)),
format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)),
format!("Node-DID:{}", sanitize_tag(manifest.node_did)),
]
.join(",")
}
/// Arweave permanent URL for a given Irys transaction ID.
pub fn arweave_url(tx_id: &str) -> String {
format!("https://arweave.net/{tx_id}")
@@ -213,9 +112,9 @@ pub fn arweave_url(tx_id: &str) -> String {
/// Build the Irys tag header value for Arweave indexing.
/// Format: comma-separated "name:value" pairs.
fn build_tags_header(anchor: &RefAnchor) -> String {
[
"App-Name:gitlawb".to_string(),
"Schema:gitlawb/ref-update/v1".to_string(),
vec![
format!("App-Name:gitlawb"),
format!("Schema:gitlawb/ref-update/v1"),
format!("Repo:{}", sanitize_tag(&anchor.repo)),
format!("Ref:{}", sanitize_tag(&anchor.ref_name)),
format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]),
@@ -243,7 +142,7 @@ mod tests {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: "0000000000000000000000000000000000000000".into(),
old_sha: "0000000000000000000000000000000000000000000000000000000000000000".into(),
new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(),
cid: Some("bafyreib5...".into()),
timestamp: "2026-03-14T00:00:00Z".into(),
@@ -270,7 +169,7 @@ mod tests {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
old_sha: "0".repeat(64),
new_sha: "a1b2c3d4".repeat(8),
cid: None,
timestamp: "2026-03-14T00:00:00Z".into(),
@@ -279,131 +178,14 @@ mod tests {
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
assert!(result.is_ok(), "anchor should succeed: {result:?}");
assert_eq!(
result.unwrap(),
"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
_mock.assert_async().await;
}
#[tokio::test]
async fn test_anchor_body_carries_real_old_sha() {
// The anchored body must serialize the real old→new transition the
// node was handed, never a zero placeholder. Regression guard for the
// push handler that used to hardcode `old_sha` to 64 zeros (#26).
let mut server = mockito::Server::new_async().await;
let real_old = "1111111111111111111111111111111111111111";
let real_new = "2222222222222222222222222222222222222222";
let _mock = server
.mock("POST", "/upload")
.match_body(mockito::Matcher::AllOf(vec![
mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)),
mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#)
.create_async()
.await;
let client = reqwest::Client::new();
let anchor = RefAnchor {
repo: "alice/myrepo".into(),
owner_did: "did:key:z6Mk...".into(),
ref_name: "refs/heads/main".into(),
old_sha: real_old.into(),
new_sha: real_new.into(),
cid: None,
timestamp: "2026-03-14T00:00:00Z".into(),
node_did: "did:key:z6Mknnd...".into(),
};
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA");
// The mock only matches when the posted JSON carries both real SHAs.
assert_eq!(result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
_mock.assert_async().await;
}
#[test]
fn test_arweave_url() {
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
assert_eq!(
url,
"https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
}
#[tokio::test]
async fn test_manifest_anchor_noop_when_url_empty() {
let client = reqwest::Client::new();
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
assert_eq!(
anchor_encrypted_manifest(&client, "", &m).await.unwrap(),
""
);
}
#[tokio::test]
async fn test_manifest_anchor_noop_when_no_blobs() {
let client = reqwest::Client::new();
let blobs: Vec<(String, String)> = vec![];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
// Non-empty URL, but no blobs: still a no-op.
assert_eq!(
anchor_encrypted_manifest(&client, "https://example.invalid", &m)
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn test_manifest_anchor_success() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/upload")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#)
.create_async()
.await;
let client = reqwest::Client::new();
let blobs = vec![("oid1".to_string(), "cid1".to_string())];
let m = EncryptedManifest {
repo: "alice/r",
owner_did: "did:key:zO",
node_did: "did:key:zN",
timestamp: "2026-06-11T00:00:00Z",
blobs: &blobs,
};
let r = anchor_encrypted_manifest(&client, &server.url(), &m).await;
assert_eq!(r.unwrap(), "MANIFESTTX123");
_mock.assert_async().await;
}
#[test]
fn manifest_blob_json_omits_recipients() {
let v = manifest_blob_json("oid1", "cidA");
assert_eq!(v["oid"], "oid1");
assert_eq!(v["cid"], "cidA");
assert!(
v.get("recipients").is_none(),
"Arweave manifest must not anchor recipient identities"
);
assert_eq!(url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
}
#[test]
-62
View File
@@ -1,62 +0,0 @@
//! #26 Split PR 2 — three-outcome recovery policy types.
//!
//! This module owns the recovery classification policy the reviewer
//! demanded: only a trustworthy, protocol-defined absence authorizes
//! a paid re-upload. Everything else stays non-terminal.
//!
//! Deliberately narrow: the ANS-104 codec lives in `ans104`, and the
//! gateway probe, the `verify_anchor` path, and the public verify
//! endpoint are deferred until the uploader, retrieval proof, and
//! recovery consumer can be proven as one vertical slice (reviewer 2,
//! round 5). A probe against a real provider contract — same-network
//! upload/read pair, redirect-safe client, envelope-supplying read
//! API, provider-documented absence evidence — belongs in that slice,
//! not here. What ships here is the policy type plus the rule that
//! only `DefinitivelyAbsent` permits spending another upload.
/// Outcome of a gateway probe for a persisted `item_id`.
// Policy surface for the recovery slice: no construction on this
// head (the probe arrives with the provider contract); the unit
// test pins the rule meanwhile.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeOutcome {
/// The persisted item was served back with a verifiable envelope
/// bound to the requested id. No re-upload allowed.
Present,
/// Trustworthy, protocol-defined proof the item was never
/// accepted. Authorizes re-upload. The exact evidence that
/// establishes this is defined by the provider contract in the
/// vertical slice that introduces the probe — never by a
/// mock-only body shape.
DefinitivelyAbsent,
/// Anything else: transport failure, oversized body, bad
/// signature, id mismatch, ambiguous gateway response. The
/// outbox stays non-terminal.
Indeterminate,
}
impl ProbeOutcome {
/// True iff the recovery code is allowed to spend another paid
/// upload request. Only `DefinitivelyAbsent` qualifies.
#[allow(dead_code)] // the recovery consumer lives in a later slice
pub fn permits_reupload(self) -> bool {
matches!(self, ProbeOutcome::DefinitivelyAbsent)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Only `DefinitivelyAbsent` authorizes a paid re-upload. This is
/// the policy the reviewer named: collapsing any ambiguous outcome
/// to absent spends a second immutable upload for the same
/// transition.
#[test]
fn only_definitively_absent_authorizes_reupload() {
assert!(!ProbeOutcome::Present.permits_reupload());
assert!(ProbeOutcome::DefinitivelyAbsent.permits_reupload());
assert!(!ProbeOutcome::Indeterminate.permits_reupload());
}
}
+43 -653
View File
@@ -1,5 +1,5 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
@@ -8,64 +8,15 @@ use http_body_util::BodyExt;
use serde_json::json;
use std::collections::HashMap;
use gitlawb_core::did::Did;
use gitlawb_core::ucan::Ucan;
use crate::state::AppState;
/// The authenticated agent's DID, injected into request extensions by `require_signature`.
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
/// 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 {
crate::api::did_matches(caller, &record.owner_did)
}
use gitlawb_core::http_sig::{
build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS,
};
use gitlawb_core::identity::verify;
/// Per-route-group ceiling on how much request body [`require_signature`] will
/// buffer BEFORE it has authenticated anything.
///
/// This is middleware state rather than a constant because a single number cannot
/// serve both a 2 GB git push and a 2 MB profile update: the git write group
/// passes `GITLAWB_MAX_PACK_BYTES`, every other signed group passes
/// [`crate::server::SIGNED_BODY_LIMIT`]. Making it a required piece of state means
/// a new signed route group cannot be wired up without choosing a limit.
#[derive(Clone, Copy, Debug)]
pub struct SignatureBodyLimit(pub usize);
/// The hint for an unresolvable DID, chosen by method. The hint is only true
/// for a method we have no resolver for: a did:key that parsed and then failed
/// on its key material (a small-order key, wrong multicodec, wrong length)
/// would be sent looking for the wrong problem by the alpha-support wording.
fn unresolvable_did_hint(key_id: &Did) -> &'static str {
if key_id.is_did_key() {
"the DID is a did:key whose key material did not resolve"
} else {
"only did:key is supported in alpha"
}
}
/// Bound the echoed keyid in the unresolvable-DID message. `key_id` is
/// unauthenticated request input, so reflecting it back at whatever length the
/// caller chose would amplify attacker input into the response body. The bound
/// is slack (a did:key method-id is a fixed 48 characters) and only defends
/// the echo, not the resolution.
fn bounded_did_echo(key_id: &str) -> String {
key_id.chars().take(96).collect()
}
/// Axum middleware that enforces HTTP Signature authentication (RFC 9421).
///
/// Every write request must carry:
@@ -80,48 +31,15 @@ fn bounded_did_echo(key_id: &str) -> String {
/// 4. Resolves the did:key to an Ed25519 VerifyingKey
/// 5. Rebuilds the signing string and verifies the Ed25519 signature
/// 6. Verifies Content-Digest matches the request body
pub async fn require_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
verify_signature(limit, request, next).await
}
/// The signature check itself, shared by [`require_signature`] and the optional
/// variant. `limit` bounds the pre-auth buffer; see [`SignatureBodyLimit`].
async fn verify_signature(limit: usize, request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream.
//
// The buffer is bounded, because this runs BEFORE any identity check: an
// unauthenticated caller with no DID, no signature and no repo reaches this
// line. Nothing upstream supplies the bound. On the git write routes
// `RequestBodyLimitLayer` sits on the inner router, and a later `.layer()` is
// the OUTER service, so this middleware runs first and sees the raw body. On
// every other signed group axum's 2 MB `DefaultBodyLimit` is enforced by the
// `Bytes`/`Json` extractors through a request extension, which collecting the
// body here bypasses. `Limited` stops reading at the limit rather than reading
// everything and then measuring it, which is the property that matters.
pub async fn require_signature(request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream
let (parts, body) = request.into_parts();
let body_bytes = match http_body_util::Limited::new(body, limit).collect().await {
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
// Over the limit: a distinct status from the 401 an unsigned request gets
// and the 400 below, so a client can tell "too big" from "not authenticated"
// and from "your body did not arrive".
Err(e) if e.is::<http_body_util::LengthLimitError>() => return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({
"error": "body_too_large",
"message": format!("request body exceeds the {limit}-byte limit for this route"),
})),
)
.into_response(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "unreadable_body", "message": "could not read request body" }),
),
Json(json!({ "error": "unreadable_body", "message": "could not read request body" })),
)
.into_response()
}
@@ -143,9 +61,8 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
(Some(i), Some(s)) => (i, s),
_ => {
return human_detected(
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures",
)
.into_response();
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures"
).into_response();
}
};
@@ -184,7 +101,7 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
COVERED_COMPONENTS.join(", "),
missing.join(", ")
),
"hint": "See https://twigpine.com/agents#authentication",
"hint": "See https://gitlawb.com/agents#authentication",
})),
)
.into_response();
@@ -209,14 +126,11 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
StatusCode::BAD_REQUEST,
Json(json!({
"error": "unresolvable_did",
"message": format!(
"cannot resolve DID '{}': {e}",
bounded_did_echo(&sig.key_id.to_string())
),
"hint": unresolvable_did_hint(&sig.key_id),
"message": format!("cannot resolve DID '{}': {e}", sig.key_id),
"hint": "only did:key is supported in alpha",
})),
)
.into_response();
.into_response()
}
};
@@ -229,51 +143,35 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
.unwrap_or("/")
.to_string();
// Required, never defaulted. The signing string is rebuilt from the request,
// so an absent header would make the covered `content-digest` empty on both
// sides: verification would pass and the digest-versus-body comparison below
// would have nothing to compare, leaving a signed request free to carry a
// body its signature never covered. `sign_request` always emits the header,
// so refusing here costs no conforming client anything.
let content_digest = match parts
let content_digest = parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
{
Some(v) => v.to_string(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "missing_content_digest",
"message": "Content-Digest header is required on a signed request",
})),
)
.into_response()
}
};
.unwrap_or("")
.to_string();
let mut request_values: HashMap<String, String> = HashMap::new();
request_values.insert("@method".to_string(), method);
request_values.insert("@path".to_string(), path_and_query);
request_values.insert("content-digest".to_string(), content_digest.clone());
request_values.insert("content-digest".to_string(), content_digest);
// The @signature-params value is the part of Signature-Input after "sig1="
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
let sig_params_value = sig_input
.strip_prefix("sig1=")
.unwrap_or(&sig_input);
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string =
match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
let signing_string = match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
// Verify Ed25519 signature
let sig_array: [u8; 64] = match sig.signature_bytes.as_slice().try_into() {
@@ -301,153 +199,25 @@ async fn verify_signature(limit: usize, request: Request, next: Next) -> Respons
.into_response();
}
// Verify Content-Digest matches the actual request body. Unconditional: the
// header's presence was established above, so there is no branch here that
// skips the comparison.
if content_digest != compute_content_digest(&body_bytes) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "content_digest_mismatch",
"message": "Content-Digest does not match request body",
})),
)
.into_response();
// Verify Content-Digest matches the actual request body
if let Some(claimed) = parts.headers.get("content-digest").and_then(|v| v.to_str().ok()) {
let actual = compute_content_digest(&body_bytes);
if claimed != actual {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "content_digest_mismatch",
"message": "Content-Digest does not match request body",
})),
)
.into_response();
}
}
tracing::info!(did = %sig.key_id, "✓ authenticated request");
let mut request = Request::from_parts(parts, Body::from(body_bytes));
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
next.run(request).await
}
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
/// continue when no signature attempt was made.
pub async fn optional_signature(
State(SignatureBodyLimit(limit)): State<SignatureBodyLimit>,
request: Request,
next: Next,
) -> Response {
let has_signature_headers = request.headers().contains_key("signature-input")
|| request.headers().contains_key("signature");
if has_signature_headers {
// Same pre-auth buffer, same bound: presenting signature headers is not
// authentication, so this path is as reachable as the required one.
return verify_signature(limit, request, next).await;
}
next.run(request).await
}
/// Validate a raw UCAN token string supplied in `X-Ucan`.
///
/// Checks performed:
/// 1. The token decodes to a valid [`Ucan`] structure.
/// 2. The UCAN issuer (`iss`) matches `signer_did` — the DID that signed the
/// HTTP request — preventing replay of another agent's UCAN.
/// 3. The UCAN audience (`aud`) matches `expected_aud` — the node's own DID.
/// 4. The full proof chain is cryptographically valid (signatures, expiry,
/// not-before, chain linkage, and capability attenuation).
fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
if &ucan.payload.iss != signer_did {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "invalid_ucan",
"message": format!(
"UCAN issuer {} does not match request signer {}",
ucan.payload.iss, signer_did
),
})),
));
}
ucan.verify_audience(expected_aud).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(())
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
///
/// Must be layered so that it runs after [`require_signature`], which sets the
/// [`AuthenticatedDid`] extension consumed here.
///
/// When `X-Ucan` is absent the request passes through unchanged, preserving
/// backward compatibility for agents that pre-date UCAN delegation. When the
/// header is present the full chain is validated: the UCAN issuer must match
/// the HTTP Signature identity, the audience must be this node's DID, and
/// every proof in the chain must be cryptographically sound with no capability
/// escalation.
pub async fn require_ucan_chain(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let token = match request
.headers()
.get("x-ucan")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
{
Some(t) => t,
None => return next.run(request).await,
};
let signer_did: Did = match request.extensions().get::<AuthenticatedDid>() {
Some(a) => match a.0.parse() {
Ok(did) => did,
Err(e) => {
tracing::warn!(raw_did = %a.0, err = %e, "failed to parse DID from authenticated identity");
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_identity", "message": "invalid DID in token" })),
)
.into_response();
}
},
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "invalid_ucan",
"message": "UCAN validation requires a valid HTTP Signature",
})),
)
.into_response()
}
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
tracing::debug!(did = %signer_did, "UCAN chain validated");
request.extensions_mut().insert(AuthenticatedDid(sig.key_id.to_string()));
next.run(request).await
}
@@ -465,387 +235,7 @@ fn human_detected(message: &str) -> impl IntoResponse {
"error": "not_an_agent",
"message": message,
"hint": "gl identity new && gl register",
"docs": "https://twigpine.com/agents",
"docs": "https://gitlawb.com/agents",
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{middleware, Router};
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tower::ServiceExt;
fn bootstrap_ucan(node: &Keypair, agent_did: Did) -> Ucan {
Ucan::bootstrap(node, agent_did).unwrap()
}
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
Ucan::delegate(
agent,
node_did,
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
None,
proof,
)
.unwrap()
}
#[test]
fn validate_ucan_chain_valid() {
let node = Keypair::generate();
let agent = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok());
}
/// The two-branch hint selection is the point of the unresolvable-DID
/// change: a did:key that failed on its key material must be told the key
/// material did not resolve, and any other method must be told only did:key
/// is supported. A regression that drops the is_did_key() check would send
/// a did:key caller after the wrong problem, so both branches are pinned.
#[test]
fn unresolvable_did_hint_distinguishes_key_material_from_method() {
// A real did:key whose key material fails to resolve (small-order):
// key-material hint.
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let weak_vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).unwrap();
let weak_did = Did::from_verifying_key(&weak_vk);
assert!(
weak_did.to_verifying_key().is_err(),
"fixture precondition: small-order did:key must not resolve"
);
assert_eq!(
unresolvable_did_hint(&weak_did),
"the DID is a did:key whose key material did not resolve"
);
// A method with no resolver: alpha-support hint.
let web = Did::web("example.com");
assert!(
web.to_verifying_key().is_err(),
"fixture precondition: did:web must not resolve locally"
);
assert_eq!(
unresolvable_did_hint(&web),
"only did:key is supported in alpha"
);
}
/// The unresolvable-DID message echoes the keyid, which is unauthenticated
/// request input. The echoed value must be bounded so a hostile caller
/// cannot amplify arbitrary-length input into the response body.
#[test]
fn unresolvable_did_message_bounds_the_echoed_keyid() {
let long_keyid = format!("did:key:z{}", "A".repeat(2000));
let shown = bounded_did_echo(&long_keyid);
assert!(
shown.len() <= 96,
"echoed keyid must be bounded, got {} chars",
shown.len()
);
assert!(
!shown.contains(&"A".repeat(1000)),
"the full keyid must not survive into the message"
);
}
#[test]
fn validate_ucan_chain_wrong_issuer() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
// signer_did is `other` but UCAN iss is `agent` — must be rejected
let err = validate_ucan_chain(&token, &node_did, &other.did()).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("does not match request signer"));
}
#[test]
fn validate_ucan_chain_wrong_audience() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other_node = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
// expected_aud is a different node — must be rejected
let err = validate_ucan_chain(&token, &other_node.did(), &agent_did).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("audience mismatch"));
}
#[test]
fn validate_ucan_chain_expired_proof() {
let node = Keypair::generate();
let agent = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
let proof = Ucan::issue(
&node,
agent_did.clone(),
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
Some(exp),
)
.unwrap();
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
let err = validate_ucan_chain(&token, &node_did, &agent_did).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("expired"));
}
fn make_test_state(node_did: gitlawb_core::did::Did) -> crate::state::AppState {
use crate::{config::Config, graphql, rate_limit::RateLimiter};
use clap::Parser;
let keypair = Keypair::generate();
let scan_token_key = crate::state::AppState::derive_scan_token_key(&keypair);
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
.expect("lazy pool creation should not fail");
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
let schema = Arc::new(graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
));
crate::state::AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
db,
node_did,
node_keypair: Arc::new(keypair),
p2p: None,
http_client: Arc::new(reqwest::Client::new()),
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)),
push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
close_issue_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)),
ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_work_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST,
ipfs_max_legacy_probes: crate::api::ipfs::MAX_LEGACY_PROBES_PER_REQUEST,
ipfs_legacy_scan_page_rows: crate::api::ipfs::LEGACY_SCAN_PAGE_ROWS,
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),
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)),
peer_write_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)),
shutdown_tx: tokio::sync::watch::channel(false).0,
git_read_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_write_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_push_advert_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_encrypt_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
pin_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
encrypt_inflight: crate::state::EncryptInflight::new(),
repo_write_leases: crate::state::RepoWriteLeases::new(8),
git_read_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_push_advert_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_write_per_caller: crate::rate_limit::PerCallerConcurrency::with_default_max_keys(8),
git_ipfs_walk_semaphore: Arc::new(tokio::sync::Semaphore::new(64)),
git_ipfs_walk_per_caller:
crate::rate_limit::PerCallerConcurrency::with_default_max_keys(16),
git_bin: "git".to_string(),
}
}
#[tokio::test]
async fn require_ucan_chain_no_header_passes_through() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn require_ucan_chain_missing_did_returns_401() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// x-ucan present but no AuthenticatedDid extension → 401
let req = Request::builder()
.uri("/")
.header("x-ucan", "any-token")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn require_ucan_chain_wrong_issuer_returns_401() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
// Build a valid token where iss = agent, but supply `other` as the signer.
let proof = bootstrap_ucan(&node, agent_did.clone());
let token = delegation_ucan(&agent, node_did.clone(), &proof)
.encode()
.unwrap();
let state = make_test_state(node_did);
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// AuthenticatedDid is `other`, UCAN iss is `agent` → issuer mismatch → 401
let req = Request::builder()
.uri("/")
.header("x-ucan", token)
.extension(AuthenticatedDid(other.did().to_string()))
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn require_ucan_chain_malformed_token_returns_401() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// Malformed x-ucan (invalid JSON)
let req = Request::builder()
.uri("/")
.header("x-ucan", "invalid-token-structure")
.extension(AuthenticatedDid(Keypair::generate().did().to_string()))
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body_json["error"], "invalid_ucan");
}
/// A request that signs `content-digest` as the EMPTY string and then sends
/// no Content-Digest header at all must be refused before any handler runs.
///
/// This is the whole point of requiring the header rather than defaulting it:
/// the middleware rebuilds the signing string from the request, so an absent
/// header makes the covered digest empty on both sides. The Ed25519 check
/// passes, the body-versus-digest comparison is skipped for want of a header,
/// and a signed request carries a body the signature never covered.
#[tokio::test]
async fn signed_request_without_content_digest_is_rejected() {
use base64::{engine::general_purpose::STANDARD, Engine};
use std::sync::atomic::{AtomicBool, Ordering};
static REACHED: AtomicBool = AtomicBool::new(false);
let kp = Keypair::generate();
let did = kp.did();
let created = chrono::Utc::now().timestamp();
let signature_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created={created}"#
);
let sig_params_value = &signature_input["sig1=".len()..];
let mut values: HashMap<String, String> = HashMap::new();
values.insert("@method".to_string(), "POST".to_string());
values.insert("@path".to_string(), "/x".to_string());
// Exactly what the middleware derives when the header is absent.
values.insert("content-digest".to_string(), String::new());
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &values).unwrap();
let signature = format!(
"sig1=:{}:",
STANDARD.encode(kp.sign(signing_string.as_bytes()).to_bytes())
);
let app = Router::new()
.route(
"/x",
axum::routing::post(|| async {
REACHED.store(true, Ordering::SeqCst);
StatusCode::OK
}),
)
.layer(middleware::from_fn_with_state(
SignatureBodyLimit(crate::server::SIGNED_BODY_LIMIT),
require_signature,
));
let req = Request::builder()
.method("POST")
.uri("/x")
.header("signature-input", signature_input)
.header("signature", signature)
.body(Body::from("a body no signature covers"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a signed request with no Content-Digest header must be refused"
);
let bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body["error"], "missing_content_digest",
"the refusal must name the absent Content-Digest header"
);
assert!(
!REACHED.load(Ordering::SeqCst),
"the handler must never see a request whose body no signature covers"
);
}
}
-316
View File
@@ -1,316 +0,0 @@
//! Embedded seed list of public Twigpine network nodes.
//!
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
//! merges its contents into the runtime config so a fresh `docker compose up`
//! joins the network without any manual peer configuration.
//!
//! Operators can opt out by setting `GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true` in
//! their environment — useful for isolated dev networks or testing.
//!
//! Add a node to the canonical list via PR to `bootstrap-peers.json`.
use std::str::FromStr;
use libp2p_core::Multiaddr;
use serde::Deserialize;
use tracing::{info, warn};
use crate::config::Config;
const EMBEDDED_PEERS_JSON: &str = include_str!("../../../bootstrap-peers.json");
const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
struct BootstrapList {
version: u32,
#[serde(default)]
peers: Vec<BootstrapPeer>,
}
#[derive(Debug, Deserialize)]
struct BootstrapPeer {
name: String,
#[allow(dead_code)]
operator: Option<String>,
#[allow(dead_code)]
did: Option<String>,
http_url: Option<String>,
p2p_multiaddr: Option<String>,
#[allow(dead_code)]
added: Option<String>,
}
/// Counts of newly-added entries returned by `merge_into_vecs`.
#[derive(Debug, Default, PartialEq, Eq)]
struct MergeCounts {
http: usize,
p2p: usize,
}
/// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
fn seeds_disabled() -> bool {
std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
.ok()
.filter(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
.is_some()
}
/// Parse the seed list from a JSON string, rejecting unsupported versions.
fn parse_seed_list(json: &str) -> Result<BootstrapList, String> {
let list: BootstrapList = serde_json::from_str(json).map_err(|e| e.to_string())?;
if list.version != SUPPORTED_VERSION {
return Err(format!(
"unsupported bootstrap-peers.json version: {} (expected {})",
list.version, SUPPORTED_VERSION
));
}
Ok(list)
}
/// Pure merge: appends entries from `list` to the two vectors, deduping.
/// Returns counts of entries actually added (i.e. not already present).
fn merge_into_vecs(
list: BootstrapList,
http_peers: &mut Vec<String>,
p2p_bootstrap: &mut Vec<String>,
) -> MergeCounts {
let mut counts = MergeCounts::default();
for peer in list.peers {
if let Some(url) = peer
.http_url
.as_ref()
.filter(|u| !u.is_empty() && !http_peers.contains(u))
{
http_peers.push(url.clone());
counts.http += 1;
}
if let Some(addr_str) = peer.p2p_multiaddr.as_ref().filter(|s| !s.is_empty()) {
match Multiaddr::from_str(addr_str) {
Ok(_) => {
if !p2p_bootstrap.contains(addr_str) {
p2p_bootstrap.push(addr_str.clone());
counts.p2p += 1;
}
}
Err(e) => warn!(
name = %peer.name,
addr = %addr_str,
err = %e,
"invalid p2p_multiaddr in bootstrap-peers.json — skipping"
),
}
}
}
counts
}
/// Merge the embedded seed list into the runtime config.
///
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
/// - Dedupes against entries already present (env / CLI takes precedence)
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
pub fn merge_seeds(config: &mut Config) {
if seeds_disabled() {
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
return;
}
let list = match parse_seed_list(EMBEDDED_PEERS_JSON) {
Ok(l) => l,
Err(e) => {
warn!(err = %e, "failed to load embedded bootstrap-peers.json — skipping");
return;
}
};
let counts = merge_into_vecs(list, &mut config.bootstrap_peers, &mut config.p2p_bootstrap);
if counts.http > 0 || counts.p2p > 0 {
info!(
http_peers = counts.http,
p2p_peers = counts.p2p,
"merged bootstrap seed list into config"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_v1_list() {
let json = r#"{
"version": 1,
"updated": "2026-04-29",
"peers": [
{
"name": "alpha",
"operator": "Alice",
"did": "did:key:z6MkAlice",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
"added": "2026-04-29"
}
]
}"#;
let list = parse_seed_list(json).expect("should parse");
assert_eq!(list.version, 1);
assert_eq!(list.peers.len(), 1);
assert_eq!(list.peers[0].name, "alpha");
}
#[test]
fn parse_rejects_unknown_version() {
let json = r#"{ "version": 99, "peers": [] }"#;
let err = parse_seed_list(json).expect_err("should reject");
assert!(err.contains("unsupported"));
}
#[test]
fn parse_rejects_malformed_json() {
let err = parse_seed_list("{ not json").expect_err("should reject");
assert!(!err.is_empty());
}
#[test]
fn parse_accepts_empty_peers_array() {
let json = r#"{ "version": 1, "peers": [] }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn parse_treats_missing_peers_as_empty() {
let json = r#"{ "version": 1 }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn merge_appends_new_http_and_p2p() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 1 });
assert_eq!(http, vec!["https://alpha.example.com"]);
assert_eq!(p2p, vec!["/ip4/1.2.3.4/tcp/7546"]);
}
#[test]
fn merge_dedupes_existing_entries() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "alpha", "http_url": "https://alpha.example.com" }
]
}"#,
)
.unwrap();
let mut http = vec!["https://alpha.example.com".to_string()];
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 0, "should not double-add");
assert_eq!(http.len(), 1);
}
#[test]
fn merge_skips_invalid_p2p_multiaddr() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "bad",
"http_url": "https://bad.example.com",
"p2p_multiaddr": "this is not a multiaddr"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 1, "http still added");
assert_eq!(counts.p2p, 0, "invalid p2p skipped");
assert!(p2p.is_empty());
}
#[test]
fn merge_skips_empty_strings() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "blank", "http_url": "", "p2p_multiaddr": "" }
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts::default());
}
#[test]
fn merge_handles_null_optional_fields() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"operator": null,
"did": null,
"http_url": "https://alpha.example.com",
"p2p_multiaddr": null,
"added": null
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 0 });
}
#[test]
fn embedded_seed_list_parses_successfully() {
// Regression: the canonical bootstrap-peers.json shipped in the repo
// must always be valid, since it's compiled into the binary.
parse_seed_list(EMBEDDED_PEERS_JSON)
.expect("embedded bootstrap-peers.json must always parse");
}
}
+7 -8
View File
@@ -41,18 +41,17 @@ pub async fn issue_ref_certificate(
let signature = state.node_keypair.sign_b64(&payload_bytes);
let cert = RefCertificate {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
pusher_did: pusher_did.to_string(),
node_did,
signature,
issued_at,
};
// Persist and return the row as it exists in the database (on a
// conflict the existing row survives when it is newer).
state.db.insert_ref_certificate(&cert).await
state.db.insert_ref_certificate(&cert).await?;
Ok(cert)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-478
View File
@@ -1,478 +0,0 @@
//! Encrypt-then-pin for withheld blobs (Option B1). Each withheld blob is sealed
//! to its recipient DIDs and the envelope pinned to IPFS, recorded in
//! `encrypted_blobs`. Best-effort per blob: a failure is logged and skipped,
//! never pinned in plaintext.
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::str::FromStr;
use ed25519_dalek::VerifyingKey;
use gitlawb_core::did::Did;
use gitlawb_core::encrypt::seal_blob;
use crate::db::Db;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Opaque, node-keyed fingerprint of a blob's recipient set. Stored in place of
/// the cleartext DID list so a DB compromise cannot reveal the reader set; used
/// only to detect a recipient-set change so an unchanged blob is not re-sealed.
/// Order-insensitive (the input `BTreeSet` is already sorted).
pub fn recipients_tag(node_seed: &[u8; 32], dids: &BTreeSet<String>) -> String {
let mut mac = HmacSha256::new_from_slice(node_seed).expect("HMAC accepts any key length");
mac.update(b"gitlawb/recipients-tag/v1");
for did in dids {
mac.update(b"\n");
mac.update(did.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
/// Resolve a DID string to its Ed25519 verifying key, or None if it carries no
/// inline key (e.g. did:web / did:gitlawb).
fn did_to_key(did: &str) -> Option<VerifyingKey> {
Did::from_str(did).ok()?.to_verifying_key().ok()
}
/// Resolve every recipient DID to its verifying key, all-or-nothing.
///
/// Returns `Ok(keys)` only when every DID resolves. If any DID fails, returns
/// `Err(unresolved)` listing the unresolvable DID strings so the caller can fail
/// closed rather than seal to a partial recipient set (#47): sealing to a subset
/// while recording the full set as covered permanently locks out the dropped
/// readers. Resolution is local-only, so `did:web`/`did:gitlawb` recipients (and
/// any malformed `did:key`) land in the unresolved set until off-`did:key`
/// resolution exists.
fn resolve_all_recipients(dids: &BTreeSet<String>) -> Result<Vec<VerifyingKey>, Vec<String>> {
let mut keys = Vec::with_capacity(dids.len());
let mut unresolved = Vec::new();
for did in dids {
match did_to_key(did) {
Some(k) => keys.push(k),
None => unresolved.push(did.clone()),
}
}
if unresolved.is_empty() {
Ok(keys)
} else {
Err(unresolved)
}
}
/// What to do with a single withheld blob, decided without any DB or IO so the
/// fail-closed invariant (#47) is unit-testable in isolation.
#[derive(Debug)]
enum SealPlan {
/// An existing envelope already covers exactly this recipient set; nothing to do.
SkipUnchanged,
/// The stored tag matches, but the stored reader set contains a DID that
/// must not resolve (e.g. a small-order did:key sealed before the choke
/// point landed). The old envelope is already public and cannot be
/// withdrawn, but it must be surfaced for operator action rather than
/// silently skipped as unchanged.
SkipUnresolvableStored(Vec<String>),
/// No recipient DID resolved to a key, so there is nothing to seal to.
SkipNoRecipients,
/// At least one recipient DID is unresolvable. Fail closed: never seal to a
/// partial set. Carries the unresolvable DIDs for logging.
SkipUnresolvable(Vec<String>),
/// Seal to `keys` and record coverage under `tag`.
Seal {
keys: Vec<VerifyingKey>,
tag: String,
},
}
/// Decide what to do with one blob given its desired recipient set and the tag
/// already stored for it (if any). Pure: no DB, no IO.
///
/// This is the #47 fail-closed gate in isolation: it returns `Seal` only when
/// EVERY recipient DID resolves, so no caller can seal to a partial set. A
/// changed recipient set (different tag) re-seals so a newly added reader can
/// recover the blob; reader removal is not retroactive (the old envelope is
/// already public). The comparison is on the opaque node-keyed tag, never the
/// DID list.
///
/// A matching stored tag is normally a skip, but the set is still resolved
/// before skipping: a reader that used to resolve and now must not (a
/// small-order did:key sealed before the choke point landed) is surfaced as
/// [`SealPlan::SkipUnresolvableStored`] instead of being silently treated as
/// unchanged. The old envelope is already public and cannot be withdrawn, but
/// the operator must be able to see that a persisted envelope was sealed to a
/// weak recipient.
fn plan_seal(node_seed: &[u8; 32], dids: &BTreeSet<String>, stored_tag: Option<&str>) -> SealPlan {
let tag = recipients_tag(node_seed, dids);
if stored_tag == Some(tag.as_str()) {
// The stored envelope already covers exactly this set, so there is
// nothing to re-seal. Resolve anyway so a now-unresolvable reader in an
// unchanged set is surfaced rather than hidden by the tag match.
match resolve_all_recipients(dids) {
Ok(_) => SealPlan::SkipUnchanged,
Err(unresolved) => SealPlan::SkipUnresolvableStored(unresolved),
}
} else {
match resolve_all_recipients(dids) {
Ok(keys) if keys.is_empty() => SealPlan::SkipNoRecipients,
Ok(keys) => SealPlan::Seal { keys, tag },
Err(unresolved) => SealPlan::SkipUnresolvable(unresolved),
}
}
}
/// Encrypt and pin every withheld blob. `recipients` maps blob oid -> DID set;
/// `node_seed` keys the opaque recipients tag. Returns `(oid, cid)` for each blob
/// actually sealed and recorded this call (the per-push delta), used by Option B3
/// to anchor a manifest. Recipient identities are never stored or returned.
pub async fn encrypt_and_pin(
ipfs_api: &str,
repo_path: &Path,
db: &Db,
repo_id: &str,
node_seed: &[u8; 32],
recipients: &HashMap<String, BTreeSet<String>>,
) -> Vec<(String, String)> {
let mut sealed = Vec::new();
let mut skipped_unresolvable = 0usize;
for (oid, dids) in recipients {
// A DB read failure is not a cache miss: re-sealing here would do an
// avoidable IPFS write during a partial outage. Skip and retry next push.
let stored_tag = match db.encrypted_blob_recipients_tag(repo_id, oid).await {
Ok(t) => t,
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "recipients_tag lookup failed; skipping reseal");
continue;
}
};
// Fail closed: plan_seal returns Seal only when every recipient DID
// resolves, so we never seal to a partial set and record the full set as
// covered (which would permanently lock out the dropped readers, #47).
let (keys, tag) = match plan_seal(node_seed, dids, stored_tag.as_deref()) {
SealPlan::SkipUnchanged => continue,
SealPlan::SkipUnresolvableStored(unresolved) => {
// A stored envelope already covers this exact reader set, and
// the set now contains a DID that must not resolve (a
// small-order did:key sealed before the choke point landed).
// The old envelope is already public and cannot be withdrawn
// or re-sealed; surface it so the operator can act.
let sample: Vec<&String> = unresolved.iter().take(3).collect();
tracing::warn!(
oid = %oid,
unresolved_count = unresolved.len(),
unresolved_sample = ?sample,
"stored envelope was sealed to a recipient that must not resolve; operator action may be required"
);
continue;
}
SealPlan::SkipNoRecipients => {
tracing::warn!(oid = %oid, "no resolvable recipient keys; skipping encrypted pin");
continue;
}
SealPlan::SkipUnresolvable(unresolved) => {
skipped_unresolvable += 1;
// DIDs are user-controlled (rule reader_dids); log a bounded
// sample, not an unbounded dump. Wording stays neutral about the
// cause: a malformed did:key is not DHT-pending and never will
// resolve, unlike a did:gitlawb awaiting anchoring.
let sample: Vec<&String> = unresolved.iter().take(3).collect();
tracing::warn!(
oid = %oid,
unresolved_count = unresolved.len(),
unresolved_sample = ?sample,
"unresolvable recipient DID(s); skipping encrypted pin to avoid sealing to a partial set"
);
continue;
}
SealPlan::Seal { keys, tag } => (keys, tag),
};
let data = match crate::git::store::read_object(repo_path, oid) {
Ok(Some((_t, bytes))) => bytes,
Ok(None) => {
tracing::warn!(oid = %oid, "git object not found; skipping encrypted pin");
continue;
}
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "read_object failed; skipping encrypted pin");
continue;
}
};
let envelope = match seal_blob(&data, &keys) {
Ok(e) => e,
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "seal_blob failed; skipping");
continue;
}
};
let cid = match crate::ipfs_pin::pin_git_object(ipfs_api, oid, &envelope, None).await {
Ok(c) if !c.is_empty() => c,
Ok(_) => {
tracing::warn!(oid = %oid, "pin_git_object returned no cid; skipping");
continue;
}
Err(e) => {
tracing::warn!(oid = %oid, err = %e, "pin_git_object failed; skipping");
continue;
}
};
if let Err(e) = db.record_encrypted_blob(repo_id, oid, &cid, &tag).await {
tracing::warn!(oid = %oid, err = %e, "record_encrypted_blob failed");
continue;
}
sealed.push((oid.clone(), cid.clone()));
}
// One aggregate signal so a coverage collapse is a single greppable line, not
// a per-oid scrape. In a fully-migrated did:gitlawb org every blob skips and
// recovery coverage silently drops to zero; this is the operator's cue that
// the gap is the deliberate fail-closed posture, not a malfunction.
if skipped_unresolvable > 0 {
tracing::warn!(
sealed = sealed.len(),
skipped = skipped_unresolvable,
"encrypted-pin coverage reduced: blobs skipped for unresolvable recipients"
);
}
sealed
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
/// A did:key encoding a small-order point. Well-formed, indistinguishable
/// from a real one by eye, and refused at resolution since the choke-point
/// guard landed.
fn weak_did_key() -> String {
let mut weak = [0u8; 32];
weak[0] = 1; // compressed identity point
let vk = ed25519_dalek::VerifyingKey::from_bytes(&weak).expect("decompresses");
Did::from_verifying_key(&vk).to_string()
}
/// The #47 fail-closed arm already refuses to seal to a partial recipient
/// set. Because a small-order did:key no longer RESOLVES, it lands in the
/// unresolved set and that existing arm catches it: nothing is sealed, and
/// no new code path was added to make that happen.
#[test]
fn weak_recipient_falls_into_the_existing_fail_closed_arm() {
let weak = weak_did_key();
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(weak.clone());
match plan_seal(&[0u8; 32], &dids, None) {
SealPlan::SkipUnresolvable(unresolved) => assert!(
unresolved.contains(&weak),
"the weak DID must be named in the unresolved set, got {unresolved:?}"
),
other => panic!("expected SkipUnresolvable, got {other:?}"),
}
}
/// Must-not-over-reject control: an all-legitimate recipient set still seals.
#[test]
fn legitimate_recipient_set_still_seals() {
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(did_key(11));
match plan_seal(&[0u8; 32], &dids, None) {
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2, "both recipients resolve"),
other => panic!("expected Seal, got {other:?}"),
}
}
fn did_key(seed: u8) -> String {
let vk = SigningKey::from_bytes(&[seed; 32]).verifying_key();
Did::from_verifying_key(&vk).to_string()
}
// Accepts both `&[String]` (resolve tests, built from `did_key`) and
// `&[&str]` (tag tests, built from literals).
fn set<S: AsRef<str>>(dids: &[S]) -> BTreeSet<String> {
dids.iter().map(|s| s.as_ref().to_string()).collect()
}
#[test]
fn all_did_key_recipients_resolve() {
let dids = set(&[did_key(1), did_key(2)]);
let keys = resolve_all_recipients(&dids).expect("all did:key resolve");
assert_eq!(keys.len(), 2);
}
#[test]
fn mixed_set_with_did_gitlawb_fails_closed() {
// Core #47 regression: a resolvable subset must never yield a sealable
// key set. Two did:key plus one did:gitlawb -> Err naming only the
// unresolvable DID.
let gitlawb = Did::gitlawb("zSomeDhtKey").to_string();
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
assert_eq!(unresolved, vec![gitlawb]);
}
#[test]
fn did_web_recipient_fails_closed() {
let web = Did::web("example.com").to_string();
let dids = set(&[did_key(1), web.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("did:web cannot resolve locally");
assert_eq!(unresolved, vec![web]);
}
#[test]
fn malformed_did_key_fails_closed() {
// The third state: not a method-resolution gap but a permanently broken
// did:key (invalid multibase). Must also fail closed.
let broken = "did:key:z!!!invalid".to_string();
let dids = set(&[did_key(1), broken.clone()]);
let unresolved = resolve_all_recipients(&dids).expect_err("malformed did:key fails");
assert_eq!(unresolved, vec![broken]);
}
#[test]
fn empty_set_resolves_to_empty_keys() {
let dids = BTreeSet::new();
let keys = resolve_all_recipients(&dids).expect("empty set is not an error");
assert!(keys.is_empty());
}
#[test]
fn single_unresolvable_did_returns_that_did() {
let gitlawb = Did::gitlawb("zOnlyOne").to_string();
let dids: BTreeSet<String> = [gitlawb.clone()].into_iter().collect();
let unresolved = resolve_all_recipients(&dids).expect_err("must fail closed");
assert_eq!(unresolved, vec![gitlawb]);
}
#[test]
fn tag_is_order_insensitive() {
let seed = [7u8; 32];
let a = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
let b = recipients_tag(&seed, &set(&["did:key:zB", "did:key:zA"]));
assert_eq!(a, b);
}
#[test]
fn tag_differs_for_different_sets() {
let seed = [7u8; 32];
let a = recipients_tag(&seed, &set(&["did:key:zA"]));
let b = recipients_tag(&seed, &set(&["did:key:zA", "did:key:zB"]));
assert_ne!(a, b);
}
#[test]
fn tag_is_keyed_by_node_seed() {
let dids = set(&["did:key:zA", "did:key:zB"]);
let a = recipients_tag(&[1u8; 32], &dids);
let b = recipients_tag(&[2u8; 32], &dids);
assert_ne!(
a, b,
"tag must depend on the node seed, not be a plain hash"
);
}
// plan_seal is the seal/skip decision `encrypt_and_pin` acts on. Testing it
// directly pins the #47 fail-closed invariant at the function that owns it,
// which a unit test of `resolve_all_recipients` alone cannot do (it can't
// catch the caller falling through to a partial seal).
const SEED: [u8; 32] = [9u8; 32];
#[test]
fn plan_seal_seals_when_all_recipients_resolve() {
let dids = set(&[did_key(1), did_key(2)]);
match plan_seal(&SEED, &dids, None) {
SealPlan::Seal { keys, tag } => {
assert_eq!(keys.len(), 2, "must seal to the full recipient set");
assert_eq!(
tag,
recipients_tag(&SEED, &dids),
"records the full-set tag"
);
}
other => panic!("expected Seal, got {other:?}"),
}
}
#[test]
fn plan_seal_fails_closed_on_any_unresolvable_recipient() {
// The #47 invariant at the decision boundary: one unresolvable DID among
// resolvable ones must NOT yield a Seal (which would seal a partial set).
let gitlawb = Did::gitlawb("zPending").to_string();
let dids = set(&[did_key(1), did_key(2), gitlawb.clone()]);
match plan_seal(&SEED, &dids, None) {
SealPlan::SkipUnresolvable(unresolved) => assert_eq!(unresolved, vec![gitlawb]),
other => panic!("must fail closed, never seal a partial set; got {other:?}"),
}
}
#[test]
fn plan_seal_skips_empty_recipient_set() {
let dids = BTreeSet::new();
assert!(matches!(
plan_seal(&SEED, &dids, None),
SealPlan::SkipNoRecipients
));
}
#[test]
fn plan_seal_skips_when_tag_unchanged() {
let dids = set(&[did_key(1)]);
let stored = recipients_tag(&SEED, &dids);
assert!(matches!(
plan_seal(&SEED, &dids, Some(&stored)),
SealPlan::SkipUnchanged
));
}
/// P1 remediation (review): an unchanged reader set is normally a skip, but
/// the skip must not hide a recipient that now fails to resolve. A blob
/// sealed to a small-order did:key before the choke-point guard landed keeps
/// its old CID and stays decryptable-without-a-private-key unless the
/// matching-tag path surfaces it. This pins that the stored-tag match still
/// resolves the set instead of short-circuiting.
#[test]
fn plan_seal_surfaces_unresolvable_recipient_in_unchanged_reader_set() {
let weak = weak_did_key();
let mut dids = BTreeSet::new();
dids.insert(did_key(9));
dids.insert(weak.clone());
let stored = recipients_tag(&SEED, &dids);
match plan_seal(&SEED, &dids, Some(&stored)) {
SealPlan::SkipUnresolvableStored(unresolved) => assert!(
unresolved.contains(&weak),
"the weak DID must be named in the unresolved set, got {unresolved:?}"
),
other => panic!("expected SkipUnresolvableStored, got {other:?}"),
}
}
/// Degenerate control: an empty set with a matching stored tag stays a
/// silent skip. The state is unreachable in practice (a seal is never
/// recorded for an empty reader set), so this pins only that the
/// resolve-on-match path must not turn it into a re-seal or a warning.
#[test]
fn plan_seal_unchanged_empty_set_still_skips() {
let empty = BTreeSet::new();
let empty_tag = recipients_tag(&SEED, &empty);
assert!(matches!(
plan_seal(&SEED, &empty, Some(&empty_tag)),
SealPlan::SkipUnchanged
));
}
#[test]
fn plan_seal_reseals_when_recipient_set_changed() {
// A stored tag for a DIFFERENT set is a miss: a newly added reader must
// trigger a re-seal, not be skipped as unchanged.
let dids = set(&[did_key(1), did_key(2)]);
let stale = recipients_tag(&SEED, &set(&[did_key(1)]));
match plan_seal(&SEED, &dids, Some(&stale)) {
SealPlan::Seal { keys, .. } => assert_eq!(keys.len(), 2),
other => panic!("changed recipient set must re-seal; got {other:?}"),
}
}
}
+34 -388
View File
@@ -23,161 +23,21 @@ pub enum AppError {
#[allow(dead_code)]
Forbidden(String),
#[error("icaptcha proof required: {message}")]
IcaptchaProofRequired {
message: String,
/// iCaptcha service base URL the client should solve against.
url: String,
/// Minimum proof level this node requires.
level: u32,
},
#[error("invalid request: {0}")]
BadRequest(String),
/// A DID was well-formed enough to carry to a resolver but no verifying key
/// could be derived from it. Its own code rather than plain `bad_request`
/// because the auth middleware already answers `unresolvable_did` for the
/// same failure on a request's keyid, and a client should not have to
/// substring-match a message to tell this apart from the other validation
/// failures on the same route.
#[error("unresolvable did: {0}")]
UnresolvableDid(String),
#[error("too many requests: {0}")]
TooManyRequests(String),
#[error("incomplete: {0}")]
Incomplete(String),
/// A bounded search that could not complete. `continuation`, when present, is the
/// sealed scan position the caller echoes as `?scan=` to resume where the search
/// stopped (#173 round 13, F2). It is AEAD-sealed at the mint site, never plaintext:
/// the row it names is by construction one the caller was denied (INV-13).
#[error("search incomplete: {message}")]
SearchIncomplete {
message: String,
continuation: Option<String>,
},
#[error("git error: {0}")]
Git(String),
#[error("git service timed out: {0}")]
Timeout(String),
#[error("server overloaded: {0}")]
Overloaded(String),
#[error("repository is busy")]
RepoBusy,
#[error("repository is temporarily unavailable")]
RepoUnavailable,
#[error("repository write was fenced by a concurrent publish")]
RepoWriteFenced,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("internal error: {0}")]
Internal(anyhow::Error),
}
/// Shared error code/message for "the database is unreachable", also used by
/// the degraded startup server (main.rs) and the readiness probe (server.rs)
/// so clients see one vocabulary for the condition.
pub const DB_UNAVAILABLE_CODE: &str = "db_unavailable";
pub const DB_UNAVAILABLE_MESSAGE: &str = "database is temporarily unavailable";
/// Generic client-facing message for `AppError::Internal`. The real error is
/// logged server-side; never put sqlx/anyhow detail in the HTTP body (#226).
pub const INTERNAL_ERROR_MESSAGE: &str = "an internal error occurred";
/// Generic client-facing message for non-unavailable `AppError::Db`. Query /
/// schema errors stay in logs; the HTTP body must not leak them (#226).
pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
/// Connection-level sqlx failures that mean the database is unreachable right
/// now (retryable, 503), as opposed to server-reported query errors.
fn db_unavailable(e: &sqlx::Error) -> bool {
matches!(
e,
sqlx::Error::PoolTimedOut
| sqlx::Error::PoolClosed
| sqlx::Error::Io(_)
| sqlx::Error::Tls(_)
)
}
/// The db layer returns `anyhow::Result`, so sqlx errors reach handlers inside
/// anyhow chains. Downcast them back out so the status mapping below can see
/// them — without this, every database outage surfaces as a 500 instead of a
/// 503. anyhow preserves downcastability through `.context()` layers.
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
match err.downcast::<sqlx::Error>() {
Ok(sql) => AppError::Db(sql),
// Lock contention is transient and ordinary, so it must not land as a
// 500. The internal message names the owner slug and repo, so the
// variant carries nothing: the detail stays in the log at the raise
// site and the client gets a fixed retryable body.
Err(err) => match err.downcast::<crate::git::repo_store::LockPoolBusy>() {
Ok(_) => AppError::Overloaded("git write locks at capacity, retry shortly".into()),
Err(err) => match err.downcast::<crate::git::repo_store::RepoBusy>() {
Ok(_) => AppError::RepoBusy,
// Same reasoning one rung down: a refused under-lock refresh is a
// transient storage condition, and its internal message names the
// owner slug and repo, so the variant carries nothing.
Err(err) => match err.downcast::<crate::git::repo_store::RepoUnavailable>() {
Ok(_) => AppError::RepoUnavailable,
// And one more rung: a publish the store refused twice is
// transient in the same way, and the retry is the client's
// to make. The variant carries nothing for the same reason
// as the two above.
Err(err) => match err.downcast::<crate::git::repo_store::RepoWriteFenced>()
{
Ok(_) => AppError::RepoWriteFenced,
Err(err) => AppError::Internal(err),
},
},
},
},
}
}
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// iCaptcha challenges carry structured discovery so clients don't have to
// scrape the message: the service URL and required level are returned as
// both JSON fields and `x-icaptcha-url` / `x-icaptcha-level` headers
// (mirroring the header-bearing `human_detected` response in auth/mod.rs).
if let AppError::IcaptchaProofRequired {
message,
url,
level,
} = &self
{
use axum::http::HeaderValue;
let body = Json(json!({
"error": "icaptcha_proof_required",
"message": message,
"icaptcha_url": url,
"required_level": level,
}));
let mut resp = (StatusCode::FORBIDDEN, body).into_response();
let headers = resp.headers_mut();
if let Ok(v) = HeaderValue::from_str(url) {
headers.insert("x-icaptcha-url", v);
}
if let Ok(v) = HeaderValue::from_str(&level.to_string()) {
headers.insert("x-icaptcha-level", v);
}
return resp;
}
let (status, code, message) = match &self {
AppError::RepoNotFound(r) => (
StatusCode::NOT_FOUND,
@@ -189,264 +49,50 @@ impl IntoResponse for AppError {
"repo_exists",
format!("repository '{r}' already exists"),
),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
// IcaptchaProofRequired is handled above (it carries extra headers/fields).
AppError::IcaptchaProofRequired { .. } => unreachable!("handled before this match"),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::UnresolvableDid(msg) => {
(StatusCode::BAD_REQUEST, "unresolvable_did", msg.clone())
}
AppError::TooManyRequests(msg) => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg.clone())
}
AppError::Incomplete(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "incomplete", msg.clone())
}
// A bounded search that could not complete (the CID resolver hit its
// legacy-probe or walk ceiling), distinct from the 404 that asserts a
// definitive not-found: absence was NOT proven, so the caller should
// retry rather than treat it as gone (#173, F2). 503, retryable.
AppError::SearchIncomplete { message, .. } => (
StatusCode::SERVICE_UNAVAILABLE,
"search_incomplete",
message.clone(),
AppError::NotFound(msg) => (
StatusCode::NOT_FOUND,
"not_found",
msg.clone(),
),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
// 504, distinct from the 500 git_error and from the read-gate's 404 /
// the auth 401, so the client can tell a deadline from a failure.
AppError::Timeout(msg) => (StatusCode::GATEWAY_TIMEOUT, "git_timeout", msg.clone()),
// 503 with a FIXED body: the caller should retry, and must not be told
// which repo is contended or for how long.
AppError::RepoBusy => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_busy",
"repository is busy — retry".into(),
AppError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
"not_an_agent",
msg.clone(),
),
// 503 with a FIXED body for the same reason: the caller should retry, and
// must not be told which repo could not be refreshed or why.
AppError::RepoUnavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_unavailable",
"repository is temporarily unavailable, retry".into(),
AppError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
"forbidden",
msg.clone(),
),
// 503 with a FIXED body again, and its own code: the caller should
// retry, but the condition is not contention, so a client that
// distinguishes them should be able to. The body must not say which
// repo lost its publish or to whom.
AppError::RepoWriteFenced => (
StatusCode::SERVICE_UNAVAILABLE,
"repo_write_fenced",
"repository changed underneath this write, retry".into(),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"bad_request",
msg.clone(),
),
AppError::Db(e) if db_unavailable(e) => (
StatusCode::SERVICE_UNAVAILABLE,
DB_UNAVAILABLE_CODE,
DB_UNAVAILABLE_MESSAGE.into(),
AppError::Git(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
"git_error",
msg.clone(),
),
AppError::Db(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"db_error",
e.to_string(),
),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
e.to_string(),
),
// 503 with a Retry-After (attached after this match — the shared tail
// can't carry per-variant headers). This is the single place Overloaded
// becomes a response, so it can never ship a 503 without the retry hint.
AppError::Overloaded(msg) => {
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", msg.clone())
}
// Opaque body + server log: bare `?` on sqlx paths becomes `AppError::Db`
// via `From`, so this arm (not `Internal`) is the common leak for open
// routes like GET /api/v1/repos and GET /api/v1/peers (#226).
AppError::Db(e) => {
tracing::error!(error = %e, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"db_error",
DB_ERROR_MESSAGE.into(),
)
}
// Opaque body: handlers that map with `.map_err(AppError::Internal)`
// (e.g. GET /ipfs/{cid}) land here; other DB failures usually hit `Db`.
// Log `{e:#}` so context-wrapped anyhow chains keep the leaf cause
// (Display alone is only the outermost layer; see api/repos.rs).
AppError::Internal(e) => {
tracing::error!(error = %format!("{e:#}"), "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
INTERNAL_ERROR_MESSAGE.into(),
)
}
};
let mut body = json!({
let body = Json(json!({
"error": code,
"message": message,
});
// A truncated CID search may carry the sealed position the caller echoes as
// `?scan=` to resume. Rendered as a third body field, present only when the
// shed actually left something to resume: a wrapped scan and a throttled
// request both omit it, and its ABSENCE is what tells a caller the ladder is
// over. It is opaque ciphertext; see `gitlawb_core::scan_token`.
if let AppError::SearchIncomplete {
continuation: Some(token),
..
} = &self
{
body["continuation"] = json!(token);
}
}));
let mut resp = (status, Json(body)).into_response();
// Both retryable 503s advertise when to retry: Overloaded (capacity shed) and
// SearchIncomplete (a bounded CID search cut short by a cap — retry may complete
// it). They ride the shared tail above for body/status, so the header is attached
// here rather than in bespoke early returns, keeping each variant handled once.
if matches!(
self,
AppError::Overloaded(_)
| AppError::SearchIncomplete { .. }
| AppError::RepoBusy
| AppError::RepoUnavailable
| AppError::RepoWriteFenced
) {
resp.headers_mut().insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("1"),
);
}
resp
(status, body).into_response()
}
}
pub type Result<T> = std::result::Result<T, AppError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeout_maps_to_504_distinct_from_git_500() {
assert_eq!(
AppError::Timeout("x".into()).into_response().status(),
StatusCode::GATEWAY_TIMEOUT
);
// Guard against a swap with the generic git failure (500).
assert_eq!(
AppError::Git("x".into()).into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn overloaded_maps_to_503_with_retry_after() {
let resp = AppError::Overloaded("x".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
/// #226: raw sqlx/DB detail must never appear in the Internal 500 body.
#[tokio::test]
async fn internal_error_body_is_opaque() {
use serde_json::{json, Value};
let leak = "error returned from database: relation \"repos\" does not exist";
let resp = AppError::Internal(anyhow::anyhow!("{leak}")).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
// Exact object: a new `detail` field with different sensitive text must
// also fail, not only a repeat of the original error string.
assert_eq!(
v,
json!({
"error": "internal_error",
"message": INTERNAL_ERROR_MESSAGE,
})
);
}
/// #226: `AppError::Db` query errors (the common `?` path) must also be opaque.
#[tokio::test]
async fn db_error_body_is_opaque() {
use serde_json::{json, Value};
let resp = AppError::Db(sqlx::Error::Protocol(
"error returned from database: column \"is_public\" does not exist".into(),
))
.into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
json!({
"error": "db_error",
"message": DB_ERROR_MESSAGE,
})
);
}
/// Connection-level failures must stay 503 `db_unavailable`, not collapse
/// into the opaque 500 `db_error` arm if `db_unavailable` loses a variant.
#[tokio::test]
async fn db_pool_timeout_stays_503_unavailable() {
use serde_json::{json, Value};
let resp = AppError::Db(sqlx::Error::PoolTimedOut).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
json!({
"error": DB_UNAVAILABLE_CODE,
"message": DB_UNAVAILABLE_MESSAGE,
})
);
}
/// #251: bare `?` on anyhow-wrapped sqlx relies on this downcast so a
/// closed pool becomes 503 `db_unavailable`, not 500 `internal_error`.
#[test]
fn pool_closed_via_anyhow_from_is_503_db_unavailable() {
let err: AppError = anyhow::Error::from(sqlx::Error::PoolClosed).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn lock_pool_busy_via_anyhow_from_is_503_overloaded() {
let err: AppError = anyhow::Error::new(crate::git::repo_store::LockPoolBusy).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
#[test]
fn repo_busy_unavailable_and_fenced_advertise_retry_after() {
for err in [
AppError::RepoBusy,
AppError::RepoUnavailable,
AppError::RepoWriteFenced,
] {
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"1"
);
}
}
}
+51 -205
View File
@@ -23,26 +23,19 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
use std::io::Write;
let mut child = hash_output;
// Write the blob to stdin, but always reap the child afterward even if the
// write fails, so a stdin-write error can't drop the Child unwaited and leak
// a zombie (#53).
let write_result = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(json.as_bytes()),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"git hash-object stdin unavailable",
)),
};
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin.write_all(json.as_bytes()).context("failed to write to git hash-object stdin")?;
}
let output = child.wait_with_output().context("git hash-object failed")?;
write_result.context("failed to write to git hash-object stdin")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git hash-object failed: {stderr}");
}
let hash =
String::from_utf8(output.stdout).context("git hash-object output is not valid UTF-8")?;
let hash = String::from_utf8(output.stdout)
.context("git hash-object output is not valid UTF-8")?;
let hash = hash.trim();
// Update the ref
@@ -61,36 +54,11 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
Ok(())
}
/// Remove a single issue ref while the write guard still holds the advisory lock,
/// after a definite publish refusal. Best-effort: a failed delete leaves a
/// local-only orphan, which is still better than telling the client to retry into a
/// duplicate id. Not used on `UploadUnknowable`, where the PUT may still land.
pub fn delete_issue_ref(
git_bin: &str,
repo_path: &Path,
issue_id: &str,
deadline: std::time::Instant,
) -> Result<()> {
let ref_name = format!("refs/gitlawb/issues/{issue_id}");
crate::git::visibility_pack::run_bounded_git(
git_bin,
&["update-ref", "-d", &ref_name],
repo_path,
b"",
deadline,
)?;
Ok(())
}
/// List all issue refs and return their JSON content.
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
// List all refs under refs/gitlawb/issues/
let list_output = Command::new("git")
.args([
"for-each-ref",
"--format=%(refname)",
"refs/gitlawb/issues/",
])
.args(["for-each-ref", "--format=%(refname)", "refs/gitlawb/issues/"])
.current_dir(repo_path)
.output()
.context("failed to run git for-each-ref")?;
@@ -129,22 +97,19 @@ pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
/// Returns Ok(Some(full_id)) on unique match, Ok(None) if not found,
/// Err if the prefix is ambiguous (matches more than one issue).
pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<String>> {
// The id comes from the request path and is spliced into a git revision and a
// for-each-ref pattern below. Issue ids are UUIDs, so accept only ASCII
// alphanumerics and '-': anything else (`^{blob}`, `@{0}`, `*`, `/`, `..`) is
// revision or glob syntax, never an issue id, and is reported as not found.
if id_or_prefix.is_empty()
|| !id_or_prefix
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Ok(None);
// Try exact match first — fast path for callers passing the full UUID.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let check = Command::new("git")
.args(["cat-file", "-e", &exact_ref])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
if check.status.success() {
return Ok(Some(id_or_prefix.to_string()));
}
// List all refs that start with the given string; the glob also matches the exact
// ref. The id handed back is always git's own refname, never the request input.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let prefix_glob = format!("{exact_ref}*");
// Prefix search: list all refs that start with the given string.
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
let list = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
.current_dir(repo_path)
@@ -158,42 +123,26 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter(|l| !l.trim().is_empty())
.collect();
// An exact id wins over longer ids that share it as a prefix, but only if its
// object exists: for-each-ref also lists a ref whose blob is missing, so a
// dangling exact ref falls through to the prefix rules below.
let exact = match matches.iter().find(|m| **m == exact_ref) {
Some(m) => {
let check = Command::new("git")
.args(["cat-file", "-e", m])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
check.status.success().then_some(*m)
match matches.len() {
0 => Ok(None),
1 => {
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = matches[0]
.trim()
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(matches[0].trim())
.to_string();
Ok(Some(full_id))
}
None => None,
};
let found = match exact {
Some(m) => m,
None => match matches.as_slice() {
[] => return Ok(None),
[m] => *m,
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
},
};
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = found
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(found)
.to_string();
Ok(Some(full_id))
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
}
}
/// Close an issue by updating its status to "closed" in the git ref.
@@ -207,8 +156,8 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
let raw = get_issue(repo_path, &full_id)?
.expect("ref existed in resolve but not in get — should be impossible");
let mut issue: serde_json::Value =
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
let mut issue: serde_json::Value = serde_json::from_str(&raw)
.context("invalid issue JSON in git ref")?;
issue["status"] = serde_json::Value::String("closed".to_string());
let updated = serde_json::to_string(&issue).context("failed to serialize updated issue")?;
@@ -244,6 +193,15 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
fn init_bare_repo(dir: &TempDir) {
Command::new("git")
.args(["init", "--bare"])
.current_dir(dir.path())
.output()
.unwrap();
// git hash-object -w needs a non-bare repo; use a working repo instead
}
fn init_repo(dir: &TempDir) {
Command::new("git")
.args(["init"])
@@ -267,97 +225,21 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), full_id).unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_exact_id_wins_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some("abc12345".to_string()));
}
#[test]
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
let out = Command::new("git")
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8(out.stdout).unwrap();
let hash = hash.trim();
std::fs::remove_file(
dir.path()
.join(".git/objects")
.join(&hash[..2])
.join(&hash[2..]),
)
.unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.unwrap_err().to_string().contains("ambiguous"));
assert!(close_issue(dir.path(), "abc12345").is_err());
}
#[test]
fn test_resolve_prefix_matches_unique() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_rejects_revision_syntax() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
// Each of these resolves to (or globs over) the issue blob in git, but none
// is an issue id, so the lookup must not accept it.
for crafted in [
"abc12345-0000-0000-0000-000000000000^{blob}",
"abc12345-0000-0000-0000-000000000000^{}",
"abc12345-0000-0000-0000-000000000000~0",
"*",
"",
] {
assert_eq!(
resolve_issue_id(dir.path(), crafted).unwrap(),
None,
"{crafted:?}"
);
assert_eq!(get_issue(dir.path(), crafted).unwrap(), None, "{crafted:?}");
}
}
#[test]
fn test_resolve_prefix_not_found() {
let dir = TempDir::new().unwrap();
@@ -370,55 +252,19 @@ mod tests {
fn test_resolve_ambiguous_prefix_errors() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(
dir.path(),
"abc12345-aaaa-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(
dir.path(),
"abc12345-bbbb-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), "abc12345-aaaa-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-bbbb-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ambiguous"));
}
#[test]
fn delete_issue_ref_removes_a_created_ref() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "eee88888-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"eee88888-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
delete_issue_ref(
"git",
dir.path(),
full_id,
std::time::Instant::now() + std::time::Duration::from_secs(30),
)
.unwrap();
assert_eq!(resolve_issue_id(dir.path(), full_id).unwrap(), None);
}
#[test]
fn test_close_issue_via_prefix() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "def99999-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let updated = close_issue(dir.path(), "def99999").unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
+3 -6
View File
@@ -1,8 +1,5 @@
pub mod issues;
pub mod publish;
pub mod push_delta;
pub mod repo_store;
pub mod smart_http;
pub mod store;
pub mod smart_http;
pub mod issues;
pub mod tigris;
pub mod visibility_pack;
pub mod repo_store;
-358
View File
@@ -1,358 +0,0 @@
//! The publication boundary: WHO wrote, and HOW FAR that write got.
//!
//! Four lifecycle defects on this branch shared one root cause. A resource was
//! tracked by a coarse state — "release started", "the path exists", "a row with
//! this name exists" — when the safety question was about the IDENTITY and the
//! PUBLICATION STAGE of one specific write attempt. A tail replicated refs from
//! an attempt that had not dispatched a PUT; a read served a tree whose
//! generation nobody had confirmed; a fork claimed a concurrent attempt's row and
//! deleted a successor's object; a response-loss failure was compensated as if it
//! proved non-publication.
//!
//! This module is the vocabulary that makes those questions answerable, and it is
//! deliberately free of any object-storage type:
//!
//! - [`PublishAttemptId`] — minted before the request is built, carried with the
//! bytes as user metadata, read back off the store to decide whether what is
//! published is THIS attempt's work.
//! - [`PublishStage`] / [`PublishStageCell`] — how far one attempt got, observable
//! from outside the future that is performing it, so a CANCELLED attempt can
//! still be classified.
//! - [`UploadError`] — what the client KNOWS about dispatch and commit, not just
//! which HTTP status came back. Destructive compensation is licensed only by
//! [`UploadError::proves_not_published`].
//!
//! # Porting note (#79)
//!
//! PR #79 (`feat/storage-abstraction`) deletes `git/tigris.rs` and replaces it
//! with a `BlobStore`/`RepoArchive` layer. Everything in this file is
//! backend-agnostic on purpose and is meant to survive that swap unchanged: a new
//! backend supplies its own error classifier (the one in `tigris.rs` is a single
//! private function) and keeps these types as the contract its callers read.
use std::sync::Mutex;
/// A durable identity for ONE publish attempt.
///
/// The point is reconciliation. A conditional PUT whose response was lost leaves
/// the client unable to say whether its bytes committed; an attempt id stored
/// alongside those bytes turns that into a question the store can answer, because
/// "is the published object mine?" is decidable where "did my request succeed?"
/// is not.
///
/// Fork creation passes the DB row id it is about to insert, so the object, the
/// on-disk clone and the database row are all stamped with the same attempt
/// identity and every cleanup can be made conditional on still owning all three.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishAttemptId(String);
impl PublishAttemptId {
/// A fresh identity for an attempt that has nothing else to be named after.
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
/// Name the attempt after a caller-owned identity — fork creation uses the
/// `record.id` it is about to insert, which is what ties the object back to
/// the exact row rather than to the logical owner/name.
pub fn from_owned(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for PublishAttemptId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PublishAttemptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// The object user-metadata key an attempt id travels in.
///
/// S3 lowercases user-metadata keys, so this must already be lowercase or the
/// read back would never match what was written.
pub const ATTEMPT_METADATA_KEY: &str = "gitlawb-attempt";
/// The precondition an upload is fenced on.
///
/// Object storage is the only place a fence can hold. Dropping the future of an
/// in-flight PUT does not cancel the request the server is already processing,
/// so no amount of local locking stops an abandoned writer's bytes from landing
/// after a successor has published. A conditional PUT the store itself refuses
/// is what actually stops it.
#[derive(Clone, Debug)]
pub enum UploadPrecondition {
/// Publish only if the stored object is still the generation we observed.
IfMatch(String),
/// Publish only if nothing is stored under the key yet.
IfAbsent,
/// No fence. Last writer wins.
Unconditional,
}
/// What the store holds under a key right now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredGeneration {
/// The generation a later conditional upload can fence itself on.
pub etag: Option<String>,
/// The attempt that published it, when the object carries our metadata.
/// `None` for anything written by something that does not stamp attempts
/// (an operator upload, a pre-attempt-id archive).
pub attempt: Option<String>,
}
impl StoredGeneration {
/// Is what the store holds the work of `attempt`?
///
/// This is the whole reconciliation primitive. It is deliberately a
/// three-way question collapsed to a boolean only at the point of use: an
/// object with no attempt metadata answers `false`, because "somebody else's
/// bytes" and "bytes nobody stamped" license exactly the same caution.
pub fn belongs_to(&self, attempt: &PublishAttemptId) -> bool {
self.attempt.as_deref() == Some(attempt.as_str())
}
}
/// The receipt of a publish the store ACKNOWLEDGED.
///
/// Only produced on a response the client actually read, so holding one is proof
/// of publication in a way that "the upload future returned" is not.
#[derive(Clone, Debug)]
pub struct UploadReceipt {
pub attempt: PublishAttemptId,
pub etag: Option<String>,
}
/// How far ONE publish attempt got.
///
/// Observable through [`PublishStageCell`] from outside the future doing the
/// work, which is the property the whole design turns on: a handler future that
/// is DROPPED never returns an outcome, so the only way to classify a cancelled
/// attempt is to read the stage it had reached when it was abandoned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PublishStage {
/// Nothing has been attempted.
Idle,
/// No object-storage backend is configured, so there is no publication to
/// confirm and the local write IS the durable copy. Distinct from `Idle`,
/// which means an attempt was possible and had not started: a cancellation
/// under `NoBackend` loses nothing, while a cancellation under `Idle` means
/// a publish that could have happened never did.
NoBackend,
/// The archive is being built. The request has not been constructed, let
/// alone sent. A cancellation here is a DEFINITE "no publication was
/// attempted" — not an ambiguous in-flight PUT.
PreparingArchive,
/// The request is on the wire. Its fate is unknown until the store answers,
/// and a cancellation here does NOT stop the server processing it.
PutDispatched { attempt: PublishAttemptId },
/// The store acknowledged the write. This is the ONLY stage that licenses
/// replication, a 2xx, or treating the local tree as durable.
Published {
attempt: PublishAttemptId,
etag: Option<String>,
},
/// The store definitively did not publish this attempt (a refused
/// precondition, or a failure that proves the bytes never committed).
Refused,
/// The attempt may or may not have committed and has not been reconciled.
/// Destructive compensation is NEVER licensed from here.
Ambiguous { attempt: PublishAttemptId },
}
impl PublishStage {
/// Did this attempt reach a state where the store may hold its bytes?
///
/// True from dispatch onward. `false` is what makes deleting the attempt's
/// object or local tree safe.
pub fn may_have_published(&self) -> bool {
matches!(
self,
PublishStage::PutDispatched { .. }
| PublishStage::Published { .. }
| PublishStage::Ambiguous { .. }
)
}
/// The attempt whose fate is unresolved, when there is one. This is what a
/// reconciliation HEAD is compared against.
pub fn unresolved_attempt(&self) -> Option<&PublishAttemptId> {
match self {
PublishStage::PutDispatched { attempt } | PublishStage::Ambiguous { attempt } => {
Some(attempt)
}
_ => None,
}
}
}
/// A [`PublishStage`] an in-flight upload writes and an outside observer reads.
///
/// `std::sync::Mutex` rather than an async lock on purpose: every write is a
/// field assignment with no await inside, and the reader that matters most runs
/// inside a `Drop` impl, where an async lock cannot be awaited at all.
#[derive(Debug)]
pub struct PublishStageCell(Mutex<PublishStage>);
impl PublishStageCell {
pub fn new() -> Self {
Self::seeded(PublishStage::Idle)
}
pub fn seeded(stage: PublishStage) -> Self {
Self(Mutex::new(stage))
}
pub fn set(&self, stage: PublishStage) {
*self.0.lock().expect("publish stage mutex poisoned") = stage;
}
pub fn get(&self) -> PublishStage {
self.0.lock().expect("publish stage mutex poisoned").clone()
}
}
impl Default for PublishStageCell {
fn default() -> Self {
Self::new()
}
}
/// What the client knows about whether a request it could not complete ever
/// reached the wire.
///
/// The classifier that produces this is the ONLY backend-aware code in the
/// publish path; a pluggable store (#79) supplies its own and every caller below
/// keeps working off [`UploadError`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DispatchKnowledge {
/// The request could not be built or sent. The store cannot hold these bytes.
NeverSent,
/// The request may have been sent, and may have committed.
MaybeSent,
}
/// Why an upload did not publish, split by what it leaves the caller ENTITLED TO
/// DO — not by which HTTP status came back.
///
/// The split exists because the previous shape ("precondition lost" vs "some
/// other error") made every caller treat a lost response as proof of failure.
/// Smithy timeout, dispatch and response errors all allow that the request was
/// sent and committed: a server can accept a complete conditional PUT and lose
/// or corrupt the response before the client observes success. Compensating that
/// as a definite failure deletes state that IS published.
#[derive(Debug, thiserror::Error)]
pub enum UploadError {
/// The store explicitly refused the fence (412, or 409 under create-only).
/// DEFINITE: this attempt did not publish, and a successor did.
#[error("upload precondition lost (HTTP {status})")]
PreconditionLost { status: u16 },
/// The attempt provably never committed: it failed before the request could
/// be dispatched, or the store answered a definite client-side refusal.
/// Destructive compensation is safe here and ONLY here.
#[error("upload did not reach the store: {0:#}")]
NotPublished(#[source] anyhow::Error),
/// The request may have been dispatched and may have committed; the client
/// never learned which. The attempt id is carried so the caller can ask the
/// store rather than guess.
#[error("upload outcome is unknowable (attempt {attempt}): {source:#}")]
Ambiguous {
attempt: PublishAttemptId,
#[source]
source: anyhow::Error,
},
}
impl UploadError {
/// May this caller destroy state that would be needed if the write HAD
/// landed — delete the object, drop the only local clone, invalidate the
/// cache?
///
/// Only a proven non-publication says yes. Every new variant must default to
/// `false`, which is why this is a match on the safe arms rather than a
/// negation of the unsafe one.
pub fn proves_not_published(&self) -> bool {
matches!(
self,
UploadError::PreconditionLost { .. } | UploadError::NotPublished(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_definite_outcomes_license_destructive_compensation() {
assert!(UploadError::PreconditionLost { status: 412 }.proves_not_published());
assert!(
UploadError::NotPublished(anyhow::anyhow!("no route to host")).proves_not_published()
);
assert!(
!UploadError::Ambiguous {
attempt: PublishAttemptId::new(),
source: anyhow::anyhow!("response body truncated"),
}
.proves_not_published(),
"a response-loss failure must never license deleting state the write may own"
);
}
#[test]
fn a_stage_may_have_published_only_from_dispatch_onward() {
let attempt = PublishAttemptId::new();
assert!(!PublishStage::Idle.may_have_published());
assert!(!PublishStage::NoBackend.may_have_published());
assert!(
!PublishStage::PreparingArchive.may_have_published(),
"compression has not constructed a request, let alone sent one"
);
assert!(!PublishStage::Refused.may_have_published());
assert!(PublishStage::PutDispatched {
attempt: attempt.clone()
}
.may_have_published());
assert!(PublishStage::Ambiguous {
attempt: attempt.clone()
}
.may_have_published());
assert!(PublishStage::Published {
attempt,
etag: None
}
.may_have_published());
}
#[test]
fn an_unstamped_object_never_belongs_to_an_attempt() {
let attempt = PublishAttemptId::new();
assert!(StoredGeneration {
etag: Some("\"e\"".into()),
attempt: Some(attempt.as_str().to_string()),
}
.belongs_to(&attempt));
assert!(
!StoredGeneration {
etag: Some("\"e\"".into()),
attempt: None,
}
.belongs_to(&attempt),
"bytes nobody stamped must not be claimed by this attempt"
);
assert!(!StoredGeneration {
etag: Some("\"e\"".into()),
attempt: Some("someone-else".into()),
}
.belongs_to(&attempt));
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+55 -845
View File
@@ -3,123 +3,17 @@
//! Repos are stored as `repos/v1/{owner_slug}/{repo_name}.tar.zst` — a
//! zstd-compressed tar archive of the bare repo directory.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock};
use std::path::Path;
use anyhow::{Context, Result};
use aws_sdk_s3::Client as S3Client;
use tracing::{debug, info, warn};
// The publication vocabulary lives in `git::publish`, which carries no
// object-storage types, and is re-exported here so existing `git::tigris::…`
// imports keep resolving. #79 deletes this file; `git::publish` is what survives
// the swap, and the only backend-aware code below is `classify_dispatch`.
pub use super::publish::{
DispatchKnowledge, PublishAttemptId, PublishStage, PublishStageCell, StoredGeneration,
UploadError, UploadPrecondition, UploadReceipt, ATTEMPT_METADATA_KEY,
};
/// What happened to a conditional, attempt-guarded delete.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttemptDelete {
/// The object was this attempt's and is gone.
Deleted,
/// Nothing is stored under the key.
Absent,
/// Something is stored, and it is NOT this attempt's work. Left alone.
NotOurs,
}
/// THE ONLY BACKEND-AWARE CLASSIFIER in the publish path.
///
/// It answers one question: does this failure PROVE the request never committed?
/// Anything short of proof is [`DispatchKnowledge::MaybeSent`], because the
/// caller's next move on a definite failure is destructive (delete the object,
/// drop the only local clone, invalidate the cache) and being wrong there
/// destroys published state.
///
/// - An HTTP status means the server answered. A 4xx is a refusal it took
/// BEFORE storing anything, so it proves non-publication. A 5xx does not: the
/// store can commit and then fail to report it.
/// - No status at all means no response was read. Only a construction failure
/// proves the request never left; Smithy's timeout, dispatch and response
/// errors all allow that the bytes arrived and committed while the answer was
/// lost, corrupted, or never parsed.
/// - `SdkError` is `#[non_exhaustive]`, so the fallback arm must be the CAUTIOUS
/// one. A future variant defaulting to "definitely failed" would silently
/// re-open exactly the class this closes.
///
/// #79 replaces `tigris.rs` with a `BlobStore` layer; this function is the piece
/// each backend reimplements, and nothing above it changes.
fn classify_put_failure<E, R>(
err: &aws_sdk_s3::error::SdkError<E, R>,
status: Option<u16>,
) -> DispatchKnowledge {
if let Some(status) = status {
return if (400..500).contains(&status) {
DispatchKnowledge::NeverSent
} else {
DispatchKnowledge::MaybeSent
};
}
match err {
aws_sdk_s3::error::SdkError::ConstructionFailure(_) => DispatchKnowledge::NeverSent,
_ => DispatchKnowledge::MaybeSent,
}
}
use tracing::{debug, info};
/// Wrapper around the S3 client with the configured bucket.
#[derive(Clone)]
pub struct TigrisClient {
s3: S3Client,
bucket: String,
/// Test-only seam: when set, the blocking compression inside `upload_tracked`
/// parks on this barrier. That is the ONLY window in which a publish attempt
/// can be cancelled with `PublishStage::PreparingArchive` still holding, and
/// it is unreachable from outside without a seam because compression of a
/// test-sized repo finishes in microseconds.
#[cfg(test)]
compress_gate: Option<Arc<BlockingGate>>,
}
/// A gate a BLOCKING thread parks on until a test opens it.
///
/// A condvar rather than a held `MutexGuard`: the test's assertions run while the
/// gate is shut, and holding a `std::sync::MutexGuard` across those awaits is
/// both a lint violation and a real hazard. Here the test owns no guard at all —
/// it flips a flag and notifies.
#[cfg(test)]
#[derive(Default)]
pub struct BlockingGate {
open: Mutex<bool>,
opened: std::sync::Condvar,
}
#[cfg(test)]
impl BlockingGate {
/// A gate that starts SHUT.
pub fn shut() -> Self {
Self::default()
}
fn wait(&self) {
let mut open = self.open.lock().expect("compression gate poisoned");
while !*open {
open = self
.opened
.wait(open)
.expect("compression gate poisoned while waiting");
}
}
/// Let every parked compression through. Call this at teardown so the
/// blocking thread is not stranded for the life of the process.
pub fn open(&self) {
*self.open.lock().expect("compression gate poisoned") = true;
self.opened.notify_all();
}
}
impl TigrisClient {
@@ -129,49 +23,7 @@ impl TigrisClient {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
info!(bucket = %bucket, "tigris storage client initialized");
Ok(Self {
s3,
bucket: bucket.to_string(),
#[cfg(test)]
compress_gate: None,
})
}
/// Build a client pointed at an arbitrary endpoint, for tests.
///
/// The production constructor reads the endpoint and credentials from the
/// environment, which a test cannot steer without mutating process-global
/// state. This takes both explicitly so a test can aim the client at a
/// closed port and get a prompt transport error out of `exists()`.
///
/// `RetryConfig::disabled()` is load-bearing, not tidiness: the SDK's default
/// policy retries a connection refusal with backoff, which turns each failing
/// call into seconds of waiting.
#[cfg(test)]
pub fn for_testing_with_endpoint(bucket: &str, endpoint: &str) -> Self {
use aws_sdk_s3::config::{retry::RetryConfig, Credentials, Region};
let config = aws_sdk_s3::config::Config::builder()
.endpoint_url(endpoint)
.credentials_provider(Credentials::new("test", "test", None, None, "test"))
.region(Region::new("auto"))
.retry_config(RetryConfig::disabled())
.behavior_version_latest()
.build();
Self {
s3: S3Client::from_conf(config),
bucket: bucket.to_string(),
compress_gate: None,
}
}
/// Test-only: park this client's blocking compression on `gate` until the
/// holder releases it, so a cancellation can be aimed at
/// [`PublishStage::PreparingArchive`] rather than at the PUT.
#[cfg(test)]
pub fn with_compress_gate(mut self, gate: Arc<BlockingGate>) -> Self {
self.compress_gate = Some(gate);
self
Ok(Self { s3, bucket: bucket.to_string() })
}
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
@@ -182,9 +34,7 @@ impl TigrisClient {
/// Check if a repo archive exists in Tigris.
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
.head_object()
match self.s3.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
@@ -192,7 +42,7 @@ impl TigrisClient {
{
Ok(_) => Ok(true),
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
if e.as_service_error().map_or(false, |e| e.is_not_found()) {
Ok(false)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
@@ -201,428 +51,76 @@ impl TigrisClient {
}
}
/// Read the ETag of a repo archive, or `None` when nothing is stored under
/// the key. The ETag identifies the generation a later conditional upload
/// can fence itself on.
///
/// Separate from `exists` rather than folded into it: `exists` has callers
/// that only want the boolean, and widening its return type would churn
/// every one of them for no benefit.
pub async fn head_etag(&self, owner_slug: &str, repo_name: &str) -> Result<Option<String>> {
Ok(self
.head_generation(owner_slug, repo_name)
.await?
.map(|g| g.etag)
.unwrap_or(None))
}
/// The full stored generation: the ETag AND the attempt that published it.
///
/// The attempt half is what makes a lost response recoverable. An ETag alone
/// answers "has the generation moved", which every writer sees the same way;
/// the attempt id answers "are the published bytes MINE", which is the
/// question a client whose PUT response vanished actually needs answered.
pub async fn head_generation(
&self,
owner_slug: &str,
repo_name: &str,
) -> Result<Option<StoredGeneration>> {
/// Upload a local bare repo directory to Tigris as a tar.zst archive.
pub async fn upload(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
{
Ok(out) => {
let etag = out
.e_tag()
.context(format!("tigris HEAD {key}: hit carried no ETag"))?
.to_string();
let attempt = out
.metadata()
.and_then(|m| m.get(ATTEMPT_METADATA_KEY))
.cloned();
Ok(Some(StoredGeneration {
etag: Some(etag),
attempt,
}))
}
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
Ok(None)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
}
}
}
}
debug!(key = %key, path = %local_path.display(), "uploading repo to tigris");
/// Did `attempt`'s bytes land? The reconciliation an ambiguous dispatch owes
/// before anything downstream may treat it as confirmation.
///
/// `Ok(false)` is deliberately NOT "the PUT failed": an abandoned request may
/// still be in flight, so a negative answer licenses refusing and retrying,
/// never deleting. Only `Ok(true)` upgrades an unresolved attempt to
/// published.
pub async fn attempt_landed(
&self,
owner_slug: &str,
repo_name: &str,
attempt: &PublishAttemptId,
) -> Result<bool> {
Ok(self
.head_generation(owner_slug, repo_name)
.await?
.is_some_and(|g| g.belongs_to(attempt)))
}
/// Upload a local bare repo directory to Tigris as a tar.zst archive,
/// fenced by `precondition`, under a freshly minted attempt identity.
pub async fn upload(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
precondition: UploadPrecondition,
) -> std::result::Result<UploadReceipt, UploadError> {
self.upload_tracked(
owner_slug,
repo_name,
local_path,
precondition,
PublishAttemptId::new(),
None,
)
.await
}
/// The full form: a caller-chosen attempt identity, and a stage cell the
/// upload reports its progress into.
///
/// The stage cell is the answer to "a dropped future never returns an
/// outcome". Compression runs inside `spawn_blocking` and the conditional PUT
/// is not even constructed until it finishes, so a handler cancelled during
/// compression definitely never attempted publication — but nothing could
/// observe that, because the only report was the return value of a future
/// that no longer exists. Marking [`PublishStage::PreparingArchive`] before
/// the blocking call and [`PublishStage::PutDispatched`] immediately before
/// `send()` makes that boundary readable from outside.
pub async fn upload_tracked(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
precondition: UploadPrecondition,
attempt: PublishAttemptId,
stage: Option<&PublishStageCell>,
) -> std::result::Result<UploadReceipt, UploadError> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), attempt = %attempt, "uploading repo to tigris");
if let Some(stage) = stage {
stage.set(PublishStage::PreparingArchive);
}
// Create tar.zst in memory. Nothing has been sent at this point and
// nothing can be: the request below is not constructed until this
// returns. A failure or a cancellation here is a definite
// non-publication.
let compressed = {
// Create tar.zst in memory
let archive_bytes = tokio::task::spawn_blocking({
let local_path = local_path.to_path_buf();
#[cfg(test)]
let gate = self.compress_gate.clone();
tokio::task::spawn_blocking(move || {
// Test-only seam: park INSIDE the blocking compression, which is
// the window a handler can be cancelled in while
// `PublishStage::PreparingArchive` still holds.
#[cfg(test)]
if let Some(gate) = gate {
// Blocks until the test opens the gate.
gate.wait();
}
compress_repo(&local_path)
})
.await
};
let archive_bytes = match compressed {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::NotPublished(e.context("compressing repo")));
}
Err(e) => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::NotPublished(
anyhow::Error::new(e).context("tar task panicked"),
));
}
};
move || compress_repo(&local_path)
})
.await
.context("tar task panicked")?
.context("compressing repo")?;
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
let mut req = self
.s3
.put_object()
self.s3.put_object()
.bucket(&self.bucket)
.key(&key)
// The attempt identity travels WITH the bytes. That is what makes a
// lost response recoverable: the client can HEAD the key afterwards
// and ask whether the published object is its own work, which is
// decidable, instead of asking whether its request succeeded, which
// is not.
.metadata(ATTEMPT_METADATA_KEY, attempt.as_str())
.body(body)
.content_type("application/zstd");
match &precondition {
UploadPrecondition::IfMatch(etag) => req = req.if_match(etag),
UploadPrecondition::IfAbsent => req = req.if_none_match("*"),
UploadPrecondition::Unconditional => {}
}
.content_type("application/zstd")
.send()
.await
.context(format!("tigris PUT {key}"))?;
// From HERE the bytes may reach the store. Everything downstream that
// could destroy state has to treat this stage as "maybe published".
if let Some(stage) = stage {
stage.set(PublishStage::PutDispatched {
attempt: attempt.clone(),
});
}
let sent = req.send().await;
let out = match sent {
Ok(out) => out,
Err(e) => {
// `PutObjectError` models no PreconditionFailed variant (its arms are
// EncryptionTypeMismatch, InvalidRequest, InvalidWriteOffset,
// TooManyParts, Unhandled), so a refused precondition arrives as
// `Unhandled` and matching the enum would classify it as a generic
// failure. The raw HTTP status off the response is the only place the
// answer actually lives.
//
// Read it via `raw_response()`, not a `ServiceError`-only match: the
// SDK exposes the raw response for BOTH `ServiceError` and
// `ResponseError`, and a refused conditional PUT whose error body the
// SDK cannot parse (malformed XML, premature close) surfaces as
// `ResponseError`. Matching only `ServiceError` would classify that
// unparsable 409/412 as a generic failure, and `RepoWriteGuard::release`
// would log-and-succeed instead of taking the supersede retry,
// acknowledging a write that was definitively not published.
let status = e.raw_response().map(|raw| raw.status().as_u16());
// 412 is always a lost precondition. 409 is one only when we asked
// for create-only, which is how S3-compatible stores report "the key
// already exists". Everything else, 404 included, is a real failure:
// archive keys are never deleted by the write path, so a 404 here
// means something permanent like a missing bucket or a misrouted
// endpoint, and reporting that as a lost precondition would tell a
// caller to expect a successor that does not exist.
let lost = match status {
Some(412) => true,
Some(409) => matches!(precondition, UploadPrecondition::IfAbsent),
_ => false,
};
if lost {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
return Err(UploadError::PreconditionLost {
status: status.expect("a lost precondition came from a status"),
});
}
let knowledge = classify_put_failure(&e, status);
let ctx = anyhow::Error::new(e).context(format!("tigris PUT {key}"));
return Err(match knowledge {
DispatchKnowledge::NeverSent => {
if let Some(stage) = stage {
stage.set(PublishStage::Refused);
}
UploadError::NotPublished(ctx)
}
DispatchKnowledge::MaybeSent => {
warn!(
key = %key,
attempt = %attempt,
status = ?status,
"tigris PUT failed WITHOUT proving it did not commit — the outcome \
is ambiguous and must be reconciled, not compensated"
);
if let Some(stage) = stage {
stage.set(PublishStage::Ambiguous {
attempt: attempt.clone(),
});
}
UploadError::Ambiguous {
attempt: attempt.clone(),
source: ctx,
}
}
});
}
};
let etag = out.e_tag().map(str::to_string);
if let Some(stage) = stage {
stage.set(PublishStage::Published {
attempt: attempt.clone(),
etag: etag.clone(),
});
}
info!(key = %key, attempt = %attempt, "uploaded repo to tigris");
Ok(UploadReceipt { attempt, etag })
}
/// Delete a repo archive ONLY while it is still `attempt`'s work.
///
/// The unconditional delete this replaces used the logical owner/name as its
/// cleanup authority, so a failed attempt compensating late could erase the
/// object a SUCCESSOR had already published under the same name and already
/// returned 201 for. A second name lookup before the delete only narrows that
/// window; reading the attempt id off the object and fencing the delete on
/// the generation it came from closes it.
pub async fn delete_if_attempt_matches(
&self,
owner_slug: &str,
repo_name: &str,
attempt: &PublishAttemptId,
) -> Result<AttemptDelete> {
let Some(generation) = self.head_generation(owner_slug, repo_name).await? else {
return Ok(AttemptDelete::Absent);
};
if !generation.belongs_to(attempt) {
return Ok(AttemptDelete::NotOurs);
}
let key = Self::repo_key(owner_slug, repo_name);
let mut req = self.s3.delete_object().bucket(&self.bucket).key(&key);
// The If-Match guard is what makes this atomic rather than merely
// narrowed: between the HEAD above and this call a successor can publish,
// and the store refusing on the moved generation is the only thing that
// stops the delete landing on their object.
if let Some(etag) = generation.etag.as_deref() {
req = req.if_match(etag);
}
match req.send().await {
Ok(_) => Ok(AttemptDelete::Deleted),
Err(e) => {
// A refused conditional delete means the generation moved: the
// object is no longer ours, which is a successful outcome for a
// guard whose whole job is not to touch somebody else's bytes.
if e.raw_response()
.map(|raw| raw.status().as_u16())
.is_some_and(|s| s == 412 || s == 409)
{
return Ok(AttemptDelete::NotOurs);
}
Err(anyhow::anyhow!("tigris conditional DELETE {key}: {e}"))
}
}
info!(key = %key, "uploaded repo to tigris");
Ok(())
}
/// Download a repo archive from Tigris and extract to local disk.
pub async fn download(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<()> {
self.download_to(owner_slug, repo_name, local_path, true, swap_authority)
.await
.map(|_| ())
}
/// Download a repo archive from Tigris and extract it, returning the
/// directory that was populated.
///
/// `publish` controls whether the extract is swapped into `target` in place
/// (the live-path mutation used by writes; returns `target`) or unpacked
/// into a fresh temp directory under `target`'s parent (a non-mutating
/// snapshot read; returns the temp dir, which the caller owns and cleans
/// up). The snapshot form never touches the live repo path.
pub async fn download_to(
&self,
owner_slug: &str,
repo_name: &str,
target: &super::repo_store::ValidatedRepoDiskPath,
publish: bool,
swap_authority: Option<Arc<AtomicBool>>,
) -> Result<DownloadExtract> {
pub async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %target.as_path().display(), "downloading repo from tigris");
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
let resp = self
.s3
.get_object()
let resp = self.s3.get_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.context(format!("tigris GET {key}"))?;
let data = resp
.body
.collect()
.await
let data = resp.body.collect().await
.context("reading tigris response body")?
.into_bytes();
.into_bytes()
.to_vec();
// The snapshot temp dir is decided HERE, in the async layer, before the
// extraction runs. Cleanup ownership moves into the blocking task so a
// cancelled async future cannot drop it while extraction is still running.
let snapshot_tmp = if publish {
None
} else {
let parent = target.parent().context("snapshot path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = target
.file_name()
.context("snapshot path has no file name")?
.to_string_lossy();
Some(parent.join(format!(
".{file_name}.tmp-snapshot.{}",
uuid::Uuid::new_v4()
)))
};
// Extract tar.zst to a directory.
let extracted = tokio::task::spawn_blocking({
let target = target.clone();
let snapshot_tmp = snapshot_tmp.clone();
move || -> Result<DownloadExtract> {
let result = (|| -> Result<DownloadExtract> {
if publish {
decompress_repo(&data, &target, swap_authority.as_ref())?;
return Ok(DownloadExtract::Published(()));
}
// Non-mutating snapshot: unpack into the temp dir decided above.
// The live repo path is never touched.
let tmp_dir = snapshot_tmp.expect("snapshot path was decided above");
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
let unpack = (|| -> Result<()> {
let decoder = zstd::stream::Decoder::new(&data[..])?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
Ok(())
})();
if let Err(e) = unpack {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e);
}
Ok(DownloadExtract::Snapshot(TempSnapshotDir { path: tmp_dir }))
})();
result
}
// Extract tar.zst to local path
tokio::task::spawn_blocking({
let local_path = local_path.to_path_buf();
move || decompress_repo(&data, &local_path)
})
.await
.context("extract task panicked")?
.context("extracting repo")?;
info!(key = %key, path = %target.as_path().display(), "downloaded repo from tigris");
Ok(extracted)
info!(key = %key, path = %local_path.display(), "downloaded repo from tigris");
Ok(())
}
/// Delete a repo archive from Tigris.
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.context(format!("tigris DELETE {key}"))?;
Ok(())
}
}
@@ -641,305 +139,17 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
Ok(compressed)
}
/// Per-repo-path lock serializing the publish (swap-into-place) step of
/// `decompress_repo`. Concurrent extractions unpack into isolated temp dirs in
/// parallel, but the final `remove_dir_all` + `rename` must not interleave for
/// the same `local_path`, or they race to a nondeterministic overwrite/failure.
pub(crate) fn publish_lock(local_path: &Path) -> Arc<Mutex<()>> {
// KNOWN LIMITATION: this map is never evicted — one (PathBuf, Arc<Mutex>)
// entry accrues per distinct repo path for the process lifetime. Bounded by
// the number of repos a node hosts, so it's negligible for normal use, but
// high-volume/churning deployments may want LRU or weak-ref eviction here.
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = locks.lock().expect("publish lock map poisoned");
map.entry(local_path.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
/// Owns a snapshot temp dir from extraction through handoff to [`RepoSnapshot`].
/// Dropped when the `spawn_blocking` join result is abandoned, so cancellation
/// before the outer future resumes still removes the directory.
pub(crate) struct TempSnapshotDir {
path: PathBuf,
}
impl TempSnapshotDir {
pub(crate) fn into_repo_snapshot(self) -> super::repo_store::RepoSnapshot {
let path = self.path.clone();
std::mem::forget(self);
super::repo_store::RepoSnapshot::from_owned_path(path)
}
}
impl Drop for TempSnapshotDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// What [`TigrisClient::download_to`] produced.
pub enum DownloadExtract {
/// Published into the validated live repo path.
Published(()),
/// Unpacked into a throwaway temp dir that cleans up on drop until adopted.
Snapshot(TempSnapshotDir),
}
/// Decompress a tar.zst byte vector into a local directory.
///
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
/// into a sibling temp directory first, and only swapped into place once it
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
/// good existing copy at `local_path` — on failure we discard the temp dir and
/// leave `local_path` exactly as it was.
fn decompress_repo(
data: &[u8],
local_path: &super::repo_store::ValidatedRepoDiskPath,
swap_authority: Option<&Arc<AtomicBool>>,
) -> Result<()> {
let live = local_path.as_path();
let parent = live.parent().context("repo path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = live
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
// Unique per-extraction temp dir: a fixed name would let two concurrent
// extractions of the same repo share one dir and clobber each other's
// in-progress unpack. A fresh UUID also means it can't collide with a
// leftover dir from a previously-interrupted run.
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract.{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
// Unpack into the temp dir; on any failure, clean up and bail without
// touching local_path.
let unpack = (|| -> Result<()> {
let decoder = zstd::stream::Decoder::new(data)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
Ok(())
})();
if let Err(e) = unpack {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e);
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
// Ensure parent directory exists
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent).context("creating parent dir")?;
}
std::fs::create_dir_all(local_path).context("creating repo dir")?;
// Swap through the validated-path helper so CodeQL sees the barrier before the
// remove/rename sink (`rust/path-injection`).
super::repo_store::swap_extracted_into_validated_repo(local_path, &tmp_dir, swap_authority)?;
let decoder = zstd::stream::Decoder::new(data)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(local_path).context("unpacking tar.zst")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aws_sdk_s3::primitives::ByteStream;
use futures::FutureExt;
/// The envs the probe needs, all of them, or it does not run.
///
/// `AWS_ENDPOINT_URL_S3` is included on purpose: without it the SDK resolves
/// to real AWS S3, and a probe that passed there would say nothing about
/// Tigris.
fn probe_env() -> Option<String> {
if std::env::var("GITLAWB_TIGRIS_PROBE").ok().as_deref() != Some("1") {
return None;
}
for name in [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_ENDPOINT_URL_S3",
] {
if std::env::var(name).is_err() {
eprintln!("tigris conditional-write probe: {name} is unset, skipping");
return None;
}
}
match std::env::var("GITLAWB_TIGRIS_BUCKET") {
Ok(b) if !b.is_empty() => Some(b),
_ => {
eprintln!(
"tigris conditional-write probe: GITLAWB_TIGRIS_BUCKET is unset, skipping"
);
None
}
}
}
/// One conditional PUT, reported as the status that REFUSED it, or `None`
/// when the store accepted the write.
///
/// Accepted is the interesting answer here, not an error: it means the
/// endpoint ignored the header we fenced on.
async fn conditional_put(
s3: &S3Client,
bucket: &str,
key: &str,
body: &'static [u8],
if_match: Option<&str>,
if_none_match: Option<&str>,
) -> Result<Option<u16>, String> {
let mut req = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body));
if let Some(v) = if_match {
req = req.if_match(v);
}
if let Some(v) = if_none_match {
req = req.if_none_match(v);
}
match req.send().await {
Ok(_) => Ok(None),
Err(e) => match e.raw_response() {
// Same raw-response rule as `upload`: a refused conditional PUT
// whose body the SDK cannot parse surfaces as `ResponseError`, and
// the status has to come off the raw response for both variants.
// Without it, an unparsable 409/412 would report "no HTTP
// response" here and skip the supersede retry.
Some(raw) => Ok(Some(raw.status().as_u16())),
None => Err(format!("conditional PUT {key}: no HTTP response: {e}")),
},
}
}
/// The probe body, written to RETURN its failures rather than panic on
/// them, so the caller's cleanup is reached on every arm.
async fn conditional_write_probe(s3: &S3Client, bucket: &str, key: &str) -> Result<(), String> {
// 1. A plain PUT under a throwaway key.
let seeded = s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"probe-one"))
.send()
.await
.map_err(|e| format!("seeding PUT {key}: {e}"))?;
// 2. Its ETag, which is the generation the next arm fences against.
let etag = seeded
.e_tag()
.ok_or_else(|| format!("seeding PUT {key} returned no ETag"))?
.to_string();
// 3. A deliberately wrong If-Match. A store honoring it answers 412.
let wrong = format!("\"{}\"", "0".repeat(32));
if etag.trim_matches('"') == wrong.trim_matches('"') {
return Err(format!(
"the seeded ETag {etag} collides with the deliberately wrong one, \
so this arm would prove nothing"
));
}
match conditional_put(s3, bucket, key, b"probe-two", Some(&wrong), None).await? {
Some(412) => {}
Some(status) => {
return Err(format!(
"a stale If-Match must be refused with 412, the endpoint answered {status}"
))
}
None => {
return Err(
"a stale If-Match was ACCEPTED: this endpoint does not honor If-Match, so \
the release fence cannot hold here"
.to_string(),
)
}
}
// 4. If-None-Match `*` over the object that now exists. This arm matters
// MORE than the one above. An ignored If-Match eventually surfaces as
// odd behavior, because a stale writer overwrites and someone notices
// the lost tree. An ignored If-None-Match just returns 200, so a publish
// that should have been fenced lands with no error anywhere: the silent
// no-op the bucket-type caveat on this test describes.
match conditional_put(s3, bucket, key, b"probe-three", None, Some("*")).await? {
// Either status is a pass, and the asymmetry with the If-Match arm
// above mirrors `upload`'s classifier exactly: 412 is always a lost
// precondition, and 409 is one too when we asked for create-only.
// AWS documents 409 for a create-only conflict racing a delete, so a
// store answering it is enforcing the precondition and we already
// handle it. Pinning 412 alone here would fail the probe against a
// backend that is behaving correctly, which sends whoever runs it
// chasing a fault that is not there.
Some(412) | Some(409) => {}
Some(status) => {
return Err(format!(
"create-only over an existing object must be refused with 412 or 409, \
the endpoint answered {status}"
))
}
None => {
return Err(
"If-None-Match * was ACCEPTED over an existing object: this endpoint does \
not honor create-only, so a fenced publish lands silently"
.to_string(),
)
}
}
Ok(())
}
/// Probe the REAL Tigris endpoint for the conditional-write semantics the
/// release fence depends on.
///
/// UNTIL THIS IS RUN AGAINST REAL CREDENTIALS, the fence is verified against
/// vendor documentation and an in-process mock, not against the backend it
/// runs on. The mock implements the semantics we believe Tigris has; it
/// cannot tell us whether Tigris actually has them.
///
/// The bucket matters, not just the endpoint. Tigris documents conditional
/// operations as supported on Single-region and Multi-region buckets only.
/// Global and Dual-region buckets are eventually consistent, and a
/// conditional PUT evaluated against a stale replica would make the fence a
/// silent no-op rather than an error. So point `GITLAWB_TIGRIS_BUCKET` at a
/// throwaway bucket of the SAME type production uses.
///
/// Ignored by default and additionally gated on `GITLAWB_TIGRIS_PROBE=1`,
/// because it writes to a real bucket and costs real requests. Run with:
/// `GITLAWB_TIGRIS_PROBE=1 cargo test -p gitlawb-node --bin gitlawb-node
/// tigris_honors_conditional_writes -- --ignored --nocapture`
#[tokio::test]
#[ignore = "writes to a real Tigris bucket; needs GITLAWB_TIGRIS_PROBE=1 plus credentials"]
async fn tigris_honors_conditional_writes() {
let Some(bucket) = probe_env() else {
eprintln!(
"tigris conditional-write probe: skipped. Set GITLAWB_TIGRIS_PROBE=1, \
GITLAWB_TIGRIS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and \
AWS_ENDPOINT_URL_S3 to run it."
);
return;
};
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
// A fresh key per run, so a probe that somehow orphaned an object on an
// earlier run cannot change what this one observes.
let key = format!("probe/conditional-write-{}.bin", uuid::Uuid::new_v4());
// CLEANUP MUST RUN ON EVERY ARM, and a failing assertion is precisely
// the case this probe exists to catch, so the delete cannot sit after
// the checks. The body returns its failures rather than panicking, and
// `catch_unwind` covers the panic an SDK call could still raise; either
// way the delete below is reached before the verdict is re-raised.
let outcome = std::panic::AssertUnwindSafe(conditional_write_probe(&s3, &bucket, &key))
.catch_unwind()
.await;
if let Err(e) = s3.delete_object().bucket(&bucket).key(&key).send().await {
eprintln!("tigris conditional-write probe: cleanup of {key} failed: {e}");
}
match outcome {
Ok(Ok(())) => {}
Ok(Err(msg)) => panic!("tigris conditional-write probe: {msg}"),
Err(payload) => std::panic::resume_unwind(payload),
}
}
}
File diff suppressed because it is too large. Load diff
-164
View File
@@ -14,68 +14,6 @@ use subscription::SubscriptionRoot;
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
/// Client-facing message for GraphQL resolver failures that wrap a real
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
/// detail in the GraphQL `errors` array (#250).
///
/// Kept as its own constant on this PR's base (main still renders
/// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
/// lands, fold this into that shared constant.
pub const GRAPHQL_DB_ERROR_MESSAGE: &str = "a database error occurred";
fn anyhow_has_sqlx(e: &anyhow::Error) -> bool {
e.chain().any(|c| c.downcast_ref::<sqlx::Error>().is_some())
}
/// Map an `anyhow` failure from the db layer to a GraphQL error.
///
/// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
/// message + `error!` log with the full `{e:#}` cause chain.
/// - Application/business errors (e.g. claim race, not-in-claimed-state) →
/// keep the actionable message; log at `warn!` so they are not mistaken for
/// infrastructure failures (#250 review).
pub(crate) fn graphql_db_err(e: anyhow::Error) -> async_graphql::Error {
if anyhow_has_sqlx(&e) {
tracing::error!(error = %format!("{e:#}"), "graphql database error");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
} else {
tracing::warn!(error = %format!("{e:#}"), "graphql application error");
async_graphql::Error::new(e.to_string())
}
}
/// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
/// GraphQL error.
///
/// Fail closed: only explicitly curated variants surface their `Display`
/// text. Unnamed variants (including `Git`, which may embed on-disk paths)
/// render opaque so a future addition cannot leak by default (#255 review).
pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error {
match e {
crate::error::AppError::Db(sql) => graphql_db_err(sql.into()),
crate::error::AppError::Internal(err) => {
tracing::error!(error = %format!("{err:#}"), "graphql internal error");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
}
// Curated client-safe variants — `Display` is intentional API text.
safe @ (crate::error::AppError::RepoNotFound(_)
| crate::error::AppError::RepoExists(_)
| crate::error::AppError::NotFound(_)
| crate::error::AppError::Unauthorized(_)
| crate::error::AppError::Forbidden(_)
| crate::error::AppError::BadRequest(_)
| crate::error::AppError::TooManyRequests(_)
| crate::error::AppError::Incomplete(_)) => {
tracing::warn!(error = %safe, "graphql application error");
async_graphql::Error::new(safe.to_string())
}
other => {
tracing::error!(error = %other, "graphql unclassified AppError (opaque)");
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
}
}
}
pub fn build_schema(
db: Arc<Db>,
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
@@ -87,105 +25,3 @@ pub fn build_schema(
.data(task_event_tx)
.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphql_db_err_opaques_sqlx_chain() {
let leak = "error returned from database: column \"is_public\" does not exist";
// Context layer must not hide sqlx from the chain walk (db helpers
// wrap with `.context(...)` in several places).
let err = graphql_db_err(
anyhow::Error::from(sqlx::Error::Protocol(leak.into())).context("loading repos"),
);
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!err.message.contains("is_public"));
assert!(!err.message.contains(leak));
assert!(!err.message.contains("loading repos"));
}
#[test]
fn graphql_db_err_keeps_business_message() {
let msg = "task not claimable: not found or already claimed";
let err = graphql_db_err(anyhow::anyhow!("{msg}"));
assert_eq!(err.message, msg);
}
#[test]
fn graphql_app_err_opaques_db_and_internal() {
let leak = "column \"is_public\" does not exist";
let db_err = graphql_app_err(crate::error::AppError::Db(sqlx::Error::Protocol(
leak.into(),
)));
assert_eq!(db_err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!db_err.message.contains("is_public"));
let internal = graphql_app_err(crate::error::AppError::Internal(anyhow::anyhow!(
"loading repo: {leak}"
)));
assert_eq!(internal.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!internal.message.contains("is_public"));
}
#[test]
fn graphql_app_err_keeps_safe_variant_messages() {
let err = graphql_app_err(crate::error::AppError::NotFound("widget".into()));
assert!(
err.message.contains("widget"),
"safe NotFound message must reach the client: {}",
err.message
);
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
let err = graphql_app_err(crate::error::AppError::BadRequest("bad cid".into()));
assert!(
err.message.contains("bad cid"),
"safe BadRequest message must reach the client: {}",
err.message
);
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
}
#[test]
fn graphql_app_err_opaques_unclassified_variants() {
// `Git` may embed on-disk paths from libgit2; fail closed.
let path = "/var/lib/gitlawb/repos/owner/secret.git";
let err = graphql_app_err(crate::error::AppError::Git(format!(
"failed to open '{path}'"
)));
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
assert!(!err.message.contains(path));
assert!(!err.message.contains("failed to open"));
}
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
/// through the opaque helpers, or discard the error (`|_|`). Same source-
/// scrape pattern as `api::authz_guard` (#255 review).
#[test]
fn every_graphql_map_err_uses_opaque_helpers() {
for (file, src) in [
("query.rs", include_str!("query.rs")),
("mutation.rs", include_str!("mutation.rs")),
] {
for (lineno, line) in src.lines().enumerate() {
let code = line.split("//").next().unwrap_or(line);
let Some(idx) = code.find(".map_err(") else {
continue;
};
let after = code[idx + ".map_err(".len()..].trim_start();
let ok = after.starts_with("crate::graphql::graphql_db_err")
|| after.starts_with("crate::graphql::graphql_app_err")
|| after.starts_with("|_|")
|| after.starts_with("|_ ");
assert!(
ok,
"{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
or discard (`|_|`): {line}",
lineno + 1
);
}
}
}
}
+10 -251
View File
@@ -3,21 +3,11 @@ use chrono::Utc;
use std::sync::Arc;
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::{AgentTask, Db};
use crate::state::TaskEventBroadcast;
use super::types::{AgentTaskType, CreateTaskInput, FinishTaskInput};
/// The verified signer DID for this request, or an auth error (N2). The DID is
/// attached request-scoped by the `/graphql` `optional_signature` layer; its
/// absence means the request was unsigned, so every mutation rejects.
fn require_signer<'a>(ctx: &'a Context<'_>) -> Result<&'a str> {
ctx.data::<AuthenticatedDid>()
.map(|d| d.0.as_str())
.map_err(|_| async_graphql::Error::new("authentication required"))
}
pub struct MutationRoot;
#[Object]
@@ -28,13 +18,6 @@ impl MutationRoot {
delegator_did: String,
input: CreateTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &delegator_did) {
return Err(async_graphql::Error::new(
"delegator_did must be the authenticated signer",
));
}
let delegator_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let now = Utc::now().to_rfc3339();
let task = AgentTask {
@@ -54,7 +37,7 @@ impl MutationRoot {
};
db.create_task(&task)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(AgentTaskType::from(task))
}
@@ -64,19 +47,13 @@ impl MutationRoot {
id: String,
assignee_did: String,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &assignee_did) {
return Err(async_graphql::Error::new(
"assignee_did must be the authenticated signer",
));
}
let assignee_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.claim_task(&id, &assignee_did)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "pending".to_string(),
@@ -94,31 +71,13 @@ impl MutationRoot {
by_did: String,
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &by_did) {
return Err(async_graphql::Error::new(
"by_did must be the authenticated signer",
));
}
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: binding by_did to the signer is necessary but not
// sufficient — only the task's assignee may finish it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can complete it",
));
}
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.finish_task(&id, "completed", input.result.as_deref())
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
@@ -136,31 +95,14 @@ impl MutationRoot {
by_did: String,
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let caller = require_signer(ctx)?;
if !crate::api::did_matches(caller, &by_did) {
return Err(async_graphql::Error::new(
"by_did must be the authenticated signer",
));
}
let by_did = caller.to_string();
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
// Authorize the actor: only the task's assignee may fail it.
let existing = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
return Err(async_graphql::Error::new(
"only the task assignee can fail it",
));
}
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let reason = input.reason.unwrap_or_default();
let task = db
.finish_task(&id, "failed", Some(&reason))
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
let _ = tx.send(TaskEventBroadcast {
task_id: id,
old_status: "claimed".to_string(),
@@ -171,186 +113,3 @@ impl MutationRoot {
Ok(AgentTaskType::from(task))
}
}
#[cfg(test)]
mod tests {
use crate::auth::AuthenticatedDid;
use async_graphql::{Request, Response};
use sqlx::PgPool;
fn errors(resp: &Response) -> String {
resp.errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("; ")
}
/// N2: every GraphQL mutation requires a verified signer and binds the acting
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
/// rejected; the matching signer passes the auth gate.
#[sqlx::test]
async fn each_mutation_requires_and_binds_signer(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let schema = state.graphql_schema.as_ref();
let actor = "did:key:zGQLACTORAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let other = "did:key:zGQLOTHERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let cases: Vec<(&str, String)> = vec![
(
"createTask",
format!(
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write" }}) {{ id }} }}"#
),
),
(
"claimTask",
format!(
r#"mutation {{ claimTask(id: "no-such-task", assigneeDid: "{actor}") {{ id }} }}"#
),
),
(
"completeTask",
format!(
r#"mutation {{ completeTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
),
),
(
"failTask",
format!(
r#"mutation {{ failTask(id: "no-such-task", byDid: "{actor}", input: {{}}) {{ id }} }}"#
),
),
];
for (name, q) in &cases {
let resp = schema.execute(Request::new(q.as_str())).await;
assert!(
errors(&resp).contains("authentication required"),
"{name}: unsigned mutation must be rejected: {}",
errors(&resp)
);
let resp = schema
.execute(Request::new(q.as_str()).data(AuthenticatedDid(other.into())))
.await;
assert!(
errors(&resp).contains("authenticated signer"),
"{name}: DID mismatch must be rejected: {}",
errors(&resp)
);
let resp = schema
.execute(Request::new(q.as_str()).data(AuthenticatedDid(actor.into())))
.await;
let errs = errors(&resp);
assert!(
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
"{name}: matching signer must pass the auth gate: {errs}"
);
if *name == "claimTask" {
assert!(
errs.contains("task not claimable"),
"claim race / missing task must keep its business message: {errs}"
);
assert!(
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"business error must not be rewritten as opaque DB error: {errs}"
);
}
}
}
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
#[sqlx::test]
async fn create_task_db_error_message_is_opaque(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
.execute(&pool)
.await
.unwrap();
let delegator = "did:key:zGQLDELEGATORAAAAAAAAAAAAAAAAAAAAAAAAAA";
let q = format!(
r#"mutation {{
createTask(
delegatorDid: "{delegator}",
input: {{ kind: "build", capability: "repo:write" }}
) {{ id }}
}}"#
);
let resp = state
.graphql_schema
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
.await;
let errs = errors(&resp);
assert!(
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
"sqlx fault must be opaque: {errs}"
);
assert!(
!errs.contains("column") && !errs.contains("status"),
"schema text leaked: {errs}"
);
}
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its
/// assignee, not merely signing as the by_did you pass. A signer who is not
/// the assignee is rejected even though the by_did binding passes; the
/// assignee completes.
#[sqlx::test]
async fn complete_task_requires_assignee(pool: PgPool) {
let state = crate::test_support::test_state(pool).await;
let assignee = "did:key:zGQLASSIGNEEAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let stranger = "did:key:zGQLSTRANGERBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let now = chrono::Utc::now().to_rfc3339();
let task = crate::db::AgentTask {
id: "task-g".into(),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: "did:key:zGQLDELEGATORCCCCCCCCCCCCCCCCCCCCCCCCCC".into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now,
deadline: None,
};
state.db.create_task(&task).await.expect("seed task");
state
.db
.claim_task("task-g", assignee)
.await
.expect("claim");
let schema = state.graphql_schema.as_ref();
let q = |actor: &str| {
format!(
r#"mutation {{ completeTask(id: "task-g", byDid: "{actor}", input: {{}}) {{ id status }} }}"#
)
};
// Stranger signs as themselves and passes byDid=self (so the signer
// binding passes), but is not the assignee → rejected by authorization.
let resp = schema
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
.await;
assert!(
errors(&resp).contains("assignee"),
"a non-assignee signer must be rejected: {}",
errors(&resp)
);
// The assignee completes with no error.
let resp = schema
.execute(Request::new(q(assignee)).data(AuthenticatedDid(assignee.into())))
.await;
assert!(
errors(&resp).is_empty(),
"the assignee should complete the task: {}",
errors(&resp)
);
}
}
+12 -451
View File
@@ -12,29 +12,11 @@ impl QueryRoot {
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
let repos = db
.list_all_repos_deduped()
.list_all_repos()
.await
.map_err(crate::graphql::graphql_db_err)?;
// Apply the same "/" visibility gate the REST/per-repo endpoints use so
// this surface does not enumerate private repos (#97). The caller DID is
// threaded onto the context by optional_signature; absent = anonymous.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let ids: Vec<String> = repos.iter().map(|r| r.id.clone()).collect();
let rules_by_repo = db
.list_visibility_rules_for_repos(&ids)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(repos
.into_iter()
.filter(|r| {
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, caller)
})
.map(|r| RepoType {
name: r.name,
owner_did: r.owner_did,
@@ -49,49 +31,16 @@ impl QueryRoot {
&self,
ctx: &Context<'_>,
repo: Option<String>,
#[graphql(
default = 20,
desc = "Max 200; larger requests return the newest 200 rows (no continuation cursor)."
)]
limit: i64,
#[graphql(default = 20)] limit: i64,
) -> Result<Vec<RefUpdateType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// Gate each row on the same "/" visibility decision the repos resolver
// uses, so anonymous callers get no row for a local repo they can't read
// (#112). The shared collector applies the fail-closed gate *before* the
// limit (paging past dropped private rows) so a small limit still returns
// the latest visible events, and keeps this surface byte-identical to the
// REST feed (#114). The row slug is peer-supplied, so the pure filter
// treats it as untrusted input; remote (no local match) rows pass.
let caller = ctx
.data::<crate::auth::AuthenticatedDid>()
.ok()
.map(|d| d.0.as_str());
let updates =
crate::api::events::collect_visible_ref_updates(db, repo.as_deref(), limit, caller)
.await
.map_err(crate::graphql::graphql_app_err)?;
// Resolve the trusted display owner_did per row, identical to the REST
// feed: the stored wire value is untrusted, so it is echoed only when it
// matches the canonical owner of the local repo the slug names (#P1);
// legacy None rows are attributed via an exact unique local match (#P3).
// The batch resolver issues at most one query per distinct local repo
// rather than one per event row (#P2).
let pairs: Vec<(&str, Option<&str>)> = updates
.iter()
.map(|u| (u.repo.as_str(), u.owner_did.as_deref()))
.collect();
let owner_dids = db
.resolve_ref_update_owner_dids(&pairs)
let updates = db
.list_ref_updates_filtered(repo.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
let resolved: Vec<RefUpdateType> = updates
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(updates
.into_iter()
.zip(owner_dids)
.map(|(u, owner_did)| RefUpdateType {
.map(|u| RefUpdateType {
repo: u.repo,
ref_name: u.ref_name,
old_sha: u.old_sha,
@@ -99,10 +48,8 @@ impl QueryRoot {
pusher_did: u.pusher_did,
node_did: u.node_did,
timestamp: u.timestamp,
owner_did,
})
.collect();
Ok(resolved)
.collect())
}
async fn tasks(
@@ -110,21 +57,13 @@ impl QueryRoot {
ctx: &Context<'_>,
status: Option<String>,
assignee_did: Option<String>,
#[graphql(
default = 50,
desc = "Max 200; larger requests are clamped to 200 (no error). Negative values clamp to 0."
)]
limit: i64,
#[graphql(default = 50)] limit: i64,
) -> Result<Vec<AgentTaskType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
// Clamp before SQL: a negative LIMIT is a client fault that Postgres
// rejects with 2201W, which would otherwise trip the opaque DB path
// and write an error-level log on every probe (#250 review).
let limit = limit.clamp(0, 200);
let tasks = db
.list_tasks(status.as_deref(), assignee_did.as_deref(), limit)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
}
@@ -133,385 +72,7 @@ impl QueryRoot {
let t = db
.get_task(&id)
.await
.map_err(crate::graphql::graphql_db_err)?;
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
Ok(t.map(AgentTaskType::from))
}
}
#[cfg(test)]
mod tests {
use crate::db::{Db, ReceivedRefUpdate, RepoRecord};
use chrono::Utc;
use sqlx::PgPool;
use std::sync::Arc;
const OWNER: &str = "did:key:z6MkOwner";
async fn db(pool: PgPool) -> Arc<Db> {
let db = Db::for_testing(pool);
db.run_migrations().await.unwrap();
Arc::new(db)
}
fn schema(db: Arc<Db>) -> super::super::GitlawbSchema {
let (ref_tx, _) = tokio::sync::broadcast::channel(16);
let (task_tx, _) = tokio::sync::broadcast::channel(16);
super::super::build_schema(db, ref_tx, task_tx)
}
fn repo(id: &str, owner_did: &str, name: &str, is_public: bool) -> RepoRecord {
RepoRecord {
id: id.into(),
name: name.into(),
owner_did: owner_did.into(),
description: None,
is_public,
default_branch: "main".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
disk_path: format!("/srv/{id}"),
forked_from: None,
machine_id: None,
}
}
fn ref_row(id: &str, slug: &str) -> ReceivedRefUpdate {
ReceivedRefUpdate {
id: id.into(),
node_did: "did:key:z6MkNode".into(),
pusher_did: "did:key:z6MkPusher".into(),
repo: slug.into(),
ref_name: "refs/heads/main".into(),
old_sha: "0".repeat(40),
new_sha: "a".repeat(40),
timestamp: Utc::now().to_rfc3339(),
cert_id: None,
received_at: Utc::now().to_rfc3339(),
from_peer: "peer1".into(),
owner_did: None,
}
}
/// Count `refUpdates` rows in a GraphQL response.
fn count(resp: &async_graphql::Response) -> usize {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").expect("refUpdates key")
else {
panic!("refUpdates not a list");
};
rows.len()
}
async fn anon(schema: &super::super::GitlawbSchema, query: &str) -> async_graphql::Response {
schema.execute(async_graphql::Request::new(query)).await
}
async fn authed(
schema: &super::super::GitlawbSchema,
query: &str,
did: &str,
) -> async_graphql::Response {
schema
.execute(
async_graphql::Request::new(query)
.data(crate::auth::AuthenticatedDid(did.to_string())),
)
.await
}
// Scenario 1 — anon must not get a private local repo's row on the
// repo:Some branch. This is the load-bearing RED→GREEN case.
#[sqlx::test]
async fn ref_updates_private_repo_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
// The GraphQL `repo` arg is the raw slug DB filter, so it must equal the
// stored slug to select the row at all — this is the exact leak path.
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName newSha pusherDid } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 2 — owner still sees their own private repo's row.
#[sqlx::test]
async fn ref_updates_private_repo_kept_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkOwner/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates(repo: "z6MkOwner/widget") { refName } }"#;
assert_eq!(count(&authed(&schema, q, OWNER).await), 1);
}
// Scenario 3 — unfiltered (repo:None): anon gets only the public row.
#[sqlx::test]
async fn ref_updates_unfiltered_anon_gets_only_public(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_pub", "z6MkOwner/openrepo"))
.await
.unwrap();
db.insert_ref_update(&ref_row("u_priv", "z6MkOwner/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo refName ownerDid } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 1);
// The one row returned must be the public repo's with owner_did echoed.
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
let async_graphql::Value::Object(row) = &rows[0] else {
unreachable!()
};
assert_eq!(
row.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo")
);
assert_eq!(
row.get("ownerDid").unwrap(),
&async_graphql::Value::from(OWNER),
"ownerDid must fall back to the local record's owner for legacy rows"
);
}
// Scenario 4 — alias fail-closed: private repo's row stored full-DID form.
#[sqlx::test]
async fn ref_updates_full_did_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABC", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "did:key:zABC/widget"))
.await
.unwrap();
let schema = schema(db);
// repo:None so the slug is not the DB filter key (which is verbatim);
// the gate must still drop it.
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 5 — truncated-key fail-closed: 8-char-prefix owner form.
#[sqlx::test]
async fn ref_updates_truncated_key_slug_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", "did:key:zABCDEFGH", "widget", false))
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "zABCDEF/widget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 6 — remote slug (no local match) is returned to anon.
#[sqlx::test]
async fn ref_updates_remote_slug_kept_for_anon(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("r1", OWNER, "widget", false))
.await
.unwrap();
// Row whose slug matches no local repo (different owner + name).
db.insert_ref_update(&ref_row("u1", "zZZZOTHER/gadget"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 1);
}
// Scenario 7 (#114 P2) — a small limit must page past the newest rows when
// they are private, so the older public rows are still returned. Before the
// gate moved ahead of the limit this returned 0 (the newest `limit` rows were
// all private and got filtered after the SQL LIMIT). RED→GREEN.
#[sqlx::test]
async fn ref_updates_small_limit_pages_past_newest_private(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("pub", OWNER, "openrepo", true))
.await
.unwrap();
db.create_repo(&repo("priv", OWNER, "secret", false))
.await
.unwrap();
// 3 older PUBLIC rows …
for i in 0..3 {
let mut r = ref_row(&format!("pub{i}"), "z6MkOwner/openrepo");
r.timestamp = format!("2026-07-01T10:00:0{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
// … then 5 NEWER PRIVATE rows (the newest in the feed).
for i in 0..5 {
let mut r = ref_row(&format!("priv{i}"), "z6MkOwner/secret");
r.timestamp = format!("2026-07-01T10:00:1{i}+00:00");
db.insert_ref_update(&r).await.unwrap();
}
let schema = schema(db);
// limit 3 < the 5 newest (all private): anon must still get 3 public rows.
let q = r#"{ refUpdates(limit: 3) { repo } }"#;
let resp = anon(&schema, q).await;
assert_eq!(count(&resp), 3, "paging must reach the older public rows");
let async_graphql::Value::Object(obj) = &resp.data else {
unreachable!()
};
let async_graphql::Value::List(rows) = obj.get("refUpdates").unwrap() else {
unreachable!()
};
for row in rows {
let async_graphql::Value::Object(r) = row else {
unreachable!()
};
assert_eq!(
r.get("repo").unwrap(),
&async_graphql::Value::from("z6MkOwner/openrepo"),
"every returned row must be the public repo's"
);
}
}
// Scenario 8 — a quarantined mirror is withheld on the GraphQL surface too.
// Guards that the resolver keeps delegating to the shared collector (where the
// quarantine fold lives); a REST-only test would miss a resolver that stopped.
#[sqlx::test]
async fn ref_updates_quarantined_mirror_dropped_for_anon(pool: PgPool) {
let db = db(pool).await;
db.upsert_mirror_repo("z6MkQuar", "secret", "/tmp/q", None, true)
.await
.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&anon(&schema, q).await), 0);
}
// Scenario 8b — the GraphQL surface also withholds a quarantined repo from an
// authenticated OWNER, not just anon. Without the collector's quarantine drop
// the owner short-circuit in visibility_check keeps the row on this surface
// too, so the REST owner test alone would not guard the resolver.
#[sqlx::test]
async fn ref_updates_quarantined_repo_dropped_for_owner(pool: PgPool) {
let db = db(pool).await;
db.create_repo(&repo("q1", "did:key:z6MkQuar", "secret", false))
.await
.unwrap();
db.set_repo_quarantine("q1", true).await.unwrap();
db.insert_ref_update(&ref_row("u1", "z6MkQuar/secret"))
.await
.unwrap();
let schema = schema(db);
let q = r#"{ refUpdates { repo } }"#;
assert_eq!(count(&authed(&schema, q, "did:key:z6MkQuar").await), 0);
}
/// #250: anonymous GraphQL query DB failures must not leak sqlx/schema text.
#[sqlx::test]
async fn repos_query_db_error_message_is_opaque(pool: PgPool) {
let db = db(pool.clone()).await;
db.create_repo(&repo("r1", OWNER, "widget", true))
.await
.unwrap();
sqlx::query("ALTER TABLE repos DROP COLUMN is_public")
.execute(&pool)
.await
.unwrap();
let schema = schema(db);
let resp = anon(&schema, "{ repos { name ownerDid } }").await;
assert!(
!resp.errors.is_empty(),
"DB failure must surface as a GraphQL error"
);
for err in &resp.errors {
assert_eq!(
err.message,
crate::graphql::GRAPHQL_DB_ERROR_MESSAGE,
"raw DB detail leaked into GraphQL error: {}",
err.message
);
assert!(
!err.message.contains("is_public") && !err.message.contains("column"),
"schema text leaked: {}",
err.message
);
}
}
/// #250: negative tasks(limit) must not hit Postgres (and must not 500-log).
#[sqlx::test]
async fn tasks_negative_limit_clamped(pool: PgPool) {
let db = db(pool).await;
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: -1) { id } }").await;
assert!(
resp.errors.is_empty(),
"negative limit must clamp, not fail: {:?}",
resp.errors
);
assert_eq!(count_tasks(&resp), 0);
}
/// #255: ceiling of the tasks(limit) clamp must be held (schema promises Max 200).
#[sqlx::test]
async fn tasks_limit_ceiling_clamped_to_200(pool: PgPool) {
let db = db(pool).await;
let now = Utc::now().to_rfc3339();
for i in 0..201 {
db.create_task(&crate::db::AgentTask {
id: format!("task-ceil-{i}"),
repo_id: None,
kind: "build".into(),
status: "pending".into(),
delegator_did: OWNER.into(),
assignee_did: None,
capability: "repo:write".into(),
ucan_token: None,
payload: None,
result: None,
created_at: now.clone(),
updated_at: now.clone(),
deadline: None,
})
.await
.unwrap();
}
let schema = schema(db);
let resp = anon(&schema, "{ tasks(limit: 5000) { id } }").await;
assert_eq!(count_tasks(&resp), 200, "limit above 200 must clamp to 200");
}
fn count_tasks(resp: &async_graphql::Response) -> usize {
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
let async_graphql::Value::Object(obj) = &resp.data else {
panic!("data not an object: {:?}", resp.data);
};
let async_graphql::Value::List(rows) = obj.get("tasks").expect("tasks key") else {
panic!("tasks not a list");
};
rows.len()
}
}
+11 -19
View File
@@ -1,5 +1,5 @@
use async_graphql::futures_util::Stream;
use async_graphql::{Context, Subscription};
use async_graphql::futures_util::Stream;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt as _;
@@ -11,15 +11,6 @@ pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
/// Live ref-update stream. `/graphql/ws` is mounted outside the
/// `optional_signature` layer, so this resolver has NO caller identity and
/// cannot gate per-subscriber — it relays whatever enters the broadcast
/// channel to any anonymous client. Its visibility safety therefore rests
/// entirely on the WRITE side: the push handler only sends a
/// `RefUpdateBroadcast` for repos the anonymous public may read (inside its
/// `if announce` block, `api/repos.rs`). This is a single-point invariant —
/// any new sender to `ref_update_tx` MUST be `announce`-gated, or private-repo
/// ref metadata leaks here to unauthenticated subscribers (#112/#114 class).
async fn ref_updates(
&self,
ctx: &Context<'_>,
@@ -31,7 +22,7 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let rf = repo.clone();
match r {
Ok(ev) if rf.as_deref().is_none_or(|r| ev.repo == r) => Some(RefUpdateType {
Ok(ev) if rf.as_deref().map_or(true, |r| ev.repo == r) => Some(RefUpdateType {
repo: ev.repo,
ref_name: ev.ref_name,
old_sha: ev.old_sha,
@@ -39,7 +30,6 @@ impl SubscriptionRoot {
pusher_did: ev.pusher_did,
node_did: ev.node_did,
timestamp: ev.timestamp,
owner_did: Some(ev.owner_did),
}),
_ => None,
}
@@ -57,13 +47,15 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let tf = task_id.clone();
match r {
Ok(ev) if tf.as_deref().is_none_or(|id| ev.task_id == id) => Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
}),
Ok(ev) if tf.as_deref().map_or(true, |id| ev.task_id == id) => {
Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
})
}
_ => None,
}
})
-1
View File
@@ -57,7 +57,6 @@ pub struct RefUpdateType {
pub pusher_did: String,
pub node_did: String,
pub timestamp: String,
pub owner_did: Option<String>,
}
#[derive(SimpleObject, Clone)]
-650
View File
@@ -1,650 +0,0 @@
//! iCaptcha proof-of-intelligence gate.
//!
//! Spam-prone endpoints (repo creation, agent registration) can require the
//! caller to present an iCaptcha proof: a small Ed25519-signed token minted by
//! <https://icaptcha.gitlawb.com> after the caller solves an escalating
//! challenge. We verify the proof OFFLINE (no per-request call to iCaptcha)
//! using its published public key, and bind each proof to the authenticated
//! agent DID so a proof cannot be shared between identities.
//!
//! Behaviour is controlled by `ICAPTCHA_MODE`:
//! * `off` (default) — gate is inert, nothing is checked.
//! * `shadow` — verify and log would-be rejections, but always allow.
//! * `enforce` — reject requests without a valid, sufficiently-strong proof.
//!
//! Config (env):
//! ICAPTCHA_MODE off | shadow | enforce (default off)
//! ICAPTCHA_URL base URL (default https://icaptcha.gitlawb.com)
//! ICAPTCHA_PUBKEY base64url Ed25519 public key (optional; else fetched from /v1/pubkey)
//! ICAPTCHA_REQUIRED_LEVEL minimum proof level (default 3)
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::Deserialize;
use crate::error::AppError;
const PROOF_HEADER: &str = "x-icaptcha-proof";
/// How long a mirror-admission `jti` stays in the replay ledger. The direct
/// request path spends a proof against its own `exp` (it can't be presented once
/// expired). The propagation path deliberately accepts already-expired proofs,
/// so spending against `exp` would store an already-past expiry that the next
/// sweep deletes — letting the same token admit another mirror minutes later.
/// We instead retain the mirror replay record for a long, fixed window so the
/// per-node single-use property is durable.
const MIRROR_REPLAY_RETENTION_SECS: i64 = 365 * 24 * 60 * 60;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Off,
Shadow,
Enforce,
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Off => "off",
Mode::Shadow => "shadow",
Mode::Enforce => "enforce",
}
}
}
/// Parse `ICAPTCHA_MODE`. Returns `None` for unrecognized values so the caller
/// can surface the typo instead of silently disabling the gate.
fn parse_mode(s: &str) -> Option<Mode> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "off" => Some(Mode::Off),
"shadow" => Some(Mode::Shadow),
"enforce" => Some(Mode::Enforce),
_ => None,
}
}
/// Parse `ICAPTCHA_REQUIRED_LEVEL`. Defaults to 3; warns (rather than silently
/// lowering the threshold) when a non-empty value fails to parse.
fn parse_required_level() -> u32 {
const DEFAULT: u32 = 3;
match std::env::var("ICAPTCHA_REQUIRED_LEVEL") {
Ok(v) if !v.trim().is_empty() => v.trim().parse().unwrap_or_else(|_| {
tracing::warn!(
value = %v,
default = DEFAULT,
"invalid ICAPTCHA_REQUIRED_LEVEL; using default"
);
DEFAULT
}),
_ => DEFAULT,
}
}
struct Verifier {
mode: Mode,
url: String,
required_level: u32,
key: Option<VerifyingKey>,
}
static VERIFIER: OnceLock<Verifier> = OnceLock::new();
#[derive(Deserialize, Debug)]
struct ProofClaims {
sub: String,
level: u32,
exp: i64,
/// Unique proof id, consumed once so a proof cannot be replayed.
jti: String,
}
#[derive(Deserialize)]
struct Jwk {
x: String,
}
#[derive(Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn decode_key(b64url: &str) -> Option<VerifyingKey> {
let bytes = URL_SAFE_NO_PAD.decode(b64url.trim()).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
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).
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
let jwks: Jwks = client.get(&endpoint).send().await.ok()?.json().await.ok()?;
decode_key(&jwks.keys.first()?.x)
}
/// Initialize the gate from the environment. Call once at startup. Never panics;
/// if the gate is active but no key can be loaded, it stays inert and warns.
pub async fn init() {
let raw_mode = std::env::var("ICAPTCHA_MODE").unwrap_or_default();
let mode = parse_mode(&raw_mode).unwrap_or_else(|| {
tracing::warn!(value = %raw_mode, "invalid ICAPTCHA_MODE; disabling iCaptcha gate");
Mode::Off
});
let url = std::env::var("ICAPTCHA_URL")
.unwrap_or_else(|_| "https://icaptcha.gitlawb.com".to_string());
let required_level = parse_required_level();
let key = if mode == Mode::Off {
None
} else {
match std::env::var("ICAPTCHA_PUBKEY") {
Ok(b64) if !b64.is_empty() => decode_key(&b64),
_ => fetch_key(&url).await,
}
};
if mode != Mode::Off {
if key.is_some() {
tracing::info!(mode = mode.as_str(), required_level, "iCaptcha gate active");
} else {
tracing::warn!(
mode = mode.as_str(),
"iCaptcha gate enabled but no public key could be loaded; staying inert"
);
}
}
let _ = VERIFIER.set(Verifier {
mode,
url,
required_level,
key,
});
}
/// Outcome of the synchronous, IO-free decision step.
#[derive(Debug)]
enum Decision {
/// Allow the request (off, shadow, inert/no-key, or verified non-enforcing).
Allow,
/// Enforce mode and verification failed; reject with this reason.
Reject(String),
/// Enforce mode and the proof verified; the caller must consume its `jti`
/// (and reject replays) before allowing, then may persist it.
Consume(VerifiedProof),
}
/// A verified proof: the raw token plus the claims we act on. Carried by a
/// [`ProofGuard`] so that, once consumed, the gated handler can persist it with
/// the created repo — letting the proof travel with the repo when it propagates
/// to peers (see [`admit_mirror`]).
#[derive(Debug)]
pub struct VerifiedProof {
token: String,
sub: String,
level: u32,
jti: String,
exp: i64,
}
impl VerifiedProof {
/// Persist this proof against a freshly-created repo so a mirroring peer can
/// re-verify it. Best-effort callers may ignore failures; the gate's
/// security does not depend on it (propagation just falls back to quarantine).
pub async fn record_for_repo(&self, db: &crate::db::Db, repo_id: &str) -> Result<(), AppError> {
db.record_repo_proof(
repo_id,
&self.token,
&self.sub,
self.level as i32,
&self.jti,
self.exp,
)
.await?;
Ok(())
}
}
/// A verified proof awaiting consumption. Verification (which rejects invalid or
/// missing proofs) is separated from consumption (which spends the single-use
/// `jti`) so a request rejected by later validation never burns a valid proof.
/// The caller must `consume()` this guard immediately before the gated write.
/// For off/shadow/inert there is nothing to consume.
#[must_use = "a verified iCaptcha proof must be consumed before the gated action"]
pub struct ProofGuard(Option<VerifiedProof>);
impl ProofGuard {
/// Spend the proof's `jti` (single-use) and return the verified proof so the
/// caller can persist it. A replay is rejected. Returns `None` when there is
/// nothing to consume (off/shadow/inert).
pub async fn consume(self, db: &crate::db::Db) -> Result<Option<VerifiedProof>, AppError> {
match self.0 {
Some(p) => {
if !db.consume_proof_jti(&p.jti, p.exp).await? {
let (url, level) = url_and_level();
return Err(AppError::IcaptchaProofRequired {
message: "iCaptcha proof already used (replay); solve a fresh challenge"
.to_string(),
url,
level,
});
}
Ok(Some(p))
}
None => Ok(None),
}
}
}
/// Verify the proof in `headers` belongs to `did`. Rejects missing/invalid
/// proofs early (enforce mode); off/shadow never reject. Returns a [`ProofGuard`]
/// the caller must `consume()` right before the gated write. Off/shadow/inert
/// yield a no-op guard that consumes nothing.
pub fn verify_request(headers: &HeaderMap, did: &str) -> Result<ProofGuard, AppError> {
let v = match VERIFIER.get() {
Some(v) => v,
None => return Ok(ProofGuard(None)), // not initialized -> inert
};
match decide(v, headers, did, now_secs()) {
Decision::Allow => Ok(ProofGuard(None)),
Decision::Reject(reason) => Err(reject_error(v, &reason)),
Decision::Consume(proof) => Ok(ProofGuard(Some(proof))),
}
}
fn reject_error(v: &Verifier, reason: &str) -> AppError {
AppError::IcaptchaProofRequired {
message: format!(
"iCaptcha proof required ({reason}). Solve a challenge at {} for level >= {} and resend with the {} header.",
v.url, v.required_level, PROOF_HEADER
),
url: v.url.clone(),
level: v.required_level,
}
}
/// iCaptcha service url + required level for error responses, read from the
/// initialized verifier (falls back to defaults if somehow uninitialized).
fn url_and_level() -> (String, u32) {
VERIFIER
.get()
.map(|v| (v.url.clone(), v.required_level))
.unwrap_or_else(|| ("https://icaptcha.gitlawb.com".to_string(), 3))
}
/// Mode-aware decision. Pure and IO-free (no DB; clock injected via `now`) so it
/// is fully unit-testable. The caller performs jti consumption for `Consume`.
fn decide(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Decision {
if v.mode == Mode::Off {
return Decision::Allow;
}
// Fail safe: if no public key could be loaded (e.g. iCaptcha was unreachable
// at startup), stay inert rather than rejecting every request. The operator
// already saw a startup warning. An iCaptcha hiccup must never break repo
// creation or registration.
if v.key.is_none() {
return Decision::Allow;
}
let token = headers.get(PROOF_HEADER).and_then(|h| h.to_str().ok());
let result = match token {
Some(t) => verify_token(v, t, did, now, true).map(|claims| (t, claims)),
None => Err("missing proof header".to_string()),
};
match result {
Ok((token, claims)) => match v.mode {
Mode::Enforce => Decision::Consume(VerifiedProof {
token: token.to_string(),
sub: claims.sub,
level: claims.level,
jti: claims.jti,
exp: claims.exp,
}),
// Shadow/Off: never reject, and do not consume (observational only).
_ => Decision::Allow,
},
Err(reason) => match v.mode {
Mode::Shadow => {
tracing::warn!(did = %did, reason, "iCaptcha (shadow) would reject");
Decision::Allow
}
Mode::Enforce => Decision::Reject(reason),
Mode::Off => Decision::Allow,
},
}
}
/// Header-extracting wrapper over [`verify_token`] (enforces expiry). The
/// production path inlines this in [`decide`]; retained as the unit-test entry
/// point that also exercises header extraction.
#[cfg(test)]
fn verify(v: &Verifier, headers: &HeaderMap, did: &str, now: i64) -> Result<ProofClaims, String> {
let proof = headers
.get(PROOF_HEADER)
.and_then(|h| h.to_str().ok())
.ok_or("missing proof header")?;
verify_token(v, proof, did, now, true)
}
/// Core verification of a raw proof token, separated for testability and reuse.
/// Checks the Ed25519 signature, the required level, and the DID binding. `exp`
/// is only enforced when `check_exp` is true: the direct request path enforces
/// freshness, but the propagation path ([`admit_mirror`]) does not — a proof has
/// usually expired by the time a repo mirrors, and the origin enforced freshness
/// at creation. `now` is unix seconds.
fn verify_token(
v: &Verifier,
token: &str,
did: &str,
now: i64,
check_exp: bool,
) -> Result<ProofClaims, String> {
let key = v.key.as_ref().ok_or("verifier has no public key")?;
let (payload, sig_b64) = token.split_once('.').ok_or("malformed proof")?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| "bad signature encoding")?;
let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad signature length")?;
key.verify_strict(payload.as_bytes(), &sig)
.map_err(|_| "signature verification failed")?;
let claims_bytes = URL_SAFE_NO_PAD
.decode(payload)
.map_err(|_| "bad payload encoding")?;
let claims: ProofClaims = serde_json::from_slice(&claims_bytes).map_err(|_| "bad claims")?;
if check_exp && claims.exp < now {
return Err("proof expired".to_string());
}
if claims.level < v.required_level {
return Err(format!(
"level {} below required {}",
claims.level, v.required_level
));
}
if !crate::api::did_matches(did, &claims.sub) {
return Err("proof subject does not match authenticated DID".to_string());
}
Ok(claims)
}
/// Outcome of admitting a repo mirrored from a peer.
#[derive(Debug, PartialEq, Eq)]
pub enum MirrorAdmission {
/// Admit the mirror normally (off/shadow/inert, or a valid proof in enforce).
Admit,
/// Enforce mode and the mirror's proof is missing/invalid/replayed — mirror
/// it but quarantine until an operator releases it. Carries the reason.
Quarantine(String),
}
/// Decide whether to admit a repo being mirrored from a peer, given the proof
/// token the origin served (`None` if it had none or the fetch failed) and the
/// mirrored repo's owner DID.
///
/// Off/shadow/inert always admit (shadow logs the would-be quarantine and writes
/// nothing). In enforce mode the proof is re-verified offline against the local
/// public key — signature, level, and owner-DID binding, but NOT expiry — and
/// its `jti` is consumed in the local ledger, so one solved challenge admits at
/// most one mirror per node (a malicious origin cannot reuse one proof to bless
/// many spam repos here).
///
/// LIMITATION (proof is not repo-bound): an iCaptcha proof commits only to the
/// solver's DID (`sub`), never to a repo — the proof is minted before the repo
/// exists. So the guarantee enforced here is "a level≥N challenge was solved by
/// this repo's owner DID", not "the owner intended THIS repo". A malicious origin
/// that harvests a victim's public proof (served by `get_icaptcha_proof`) can use
/// it to get ONE spam repo nominally owned by that victim admitted per node — the
/// per-node `jti` single-use caps the amplification at one repo per distinct
/// harvested proof, but does not eliminate it. Fully binding a proof to a repo
/// would require the iCaptcha service to embed a repo/target identifier in the
/// signed challenge (a protocol change, tracked as future work).
pub async fn admit_mirror(
db: &crate::db::Db,
token: Option<&str>,
owner_did: &str,
) -> MirrorAdmission {
let v = match VERIFIER.get() {
Some(v) => v,
None => return MirrorAdmission::Admit, // not initialized -> inert
};
// Off, or inert because no key could be loaded: admit unconditionally.
if v.mode == Mode::Off || v.key.is_none() {
return MirrorAdmission::Admit;
}
let shadow = v.mode == Mode::Shadow;
// Quarantine in enforce; in shadow just log and admit (observational, no IO).
let quarantine = |reason: String| -> MirrorAdmission {
if shadow {
tracing::warn!(owner = %owner_did, reason, "iCaptcha (shadow) would quarantine mirror");
MirrorAdmission::Admit
} else {
MirrorAdmission::Quarantine(reason)
}
};
let token = match token {
Some(t) => t,
None => return quarantine("origin served no iCaptcha proof".to_string()),
};
match verify_token(v, token, owner_did, now_secs(), false) {
Ok(claims) => {
if shadow {
// Observational: a valid proof would admit. Do not consume, so a
// later switch to enforce still sees the jti as fresh.
return MirrorAdmission::Admit;
}
// Retain the replay record on a fixed forward window, NOT the proof's
// own (already-past) exp — otherwise the next sweep frees the jti and
// the same token could admit another mirror here.
let retain_until = now_secs().saturating_add(MIRROR_REPLAY_RETENTION_SECS);
match db.consume_proof_jti(&claims.jti, retain_until).await {
Ok(true) => MirrorAdmission::Admit,
Ok(false) => MirrorAdmission::Quarantine(
"iCaptcha proof already used to admit another mirror".to_string(),
),
Err(e) => {
tracing::warn!(owner = %owner_did, err = %e, "iCaptcha mirror ledger error; quarantining");
MirrorAdmission::Quarantine("iCaptcha proof ledger unavailable".to_string())
}
}
}
Err(reason) => quarantine(reason),
}
}
#[cfg(test)]
mod tests {
use super::*;
// Real values captured from https://icaptcha.gitlawb.com (a live proof).
const PUBKEY_X: &str = "xjyPNqIbvc9U-kwXW6u9mDqRJ7E2UUMOaJdUWhpEXq8";
const PROOF: &str = "eyJzdWIiOiJkaWQ6a2V5Onp0ZXN0IiwibGV2ZWwiOjMsImlzcyI6ImljYXB0Y2hhIiwiaWF0IjoxNzgyNTcyODUxLCJleHAiOjE3ODI1NzMxNTEsImp0aSI6IjRiNTIyOGE1YmVkNzEyMmRlZTlmNDdmZiJ9.5UXVPZ8Eo91VnlcvgDXtW-Fx7J2jr7h535SAstQEpigxBr7FF7V6R0XB4PBDgdoBPnhdH_kVEfRPfdHPSdB0CA";
const SUB: &str = "did:key:ztest";
const IAT: i64 = 1782572851; // within the proof's validity window
fn verifier(level: u32) -> Verifier {
Verifier {
mode: Mode::Enforce,
url: "https://icaptcha.gitlawb.com".to_string(),
required_level: level,
key: decode_key(PUBKEY_X),
}
}
fn headers_with(proof: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(PROOF_HEADER, proof.parse().unwrap());
h
}
#[test]
fn accepts_a_real_proof() {
let v = verifier(3);
assert!(verify(&v, &headers_with(PROOF), SUB, IAT).is_ok());
}
#[test]
fn rejects_expired_proof() {
let v = verifier(3);
let err = verify(&v, &headers_with(PROOF), SUB, 9_999_999_999).unwrap_err();
assert!(err.contains("expired"), "{err}");
}
#[test]
fn rejects_wrong_did() {
let v = verifier(3);
let err = verify(&v, &headers_with(PROOF), "did:key:zsomeoneelse", IAT).unwrap_err();
assert!(err.contains("subject"), "{err}");
}
#[test]
fn rejects_insufficient_level() {
let v = verifier(5); // proof is level 3
let err = verify(&v, &headers_with(PROOF), SUB, IAT).unwrap_err();
assert!(err.contains("below required"), "{err}");
}
#[test]
fn rejects_tampered_signature() {
let v = verifier(3);
// Flip one base64url char in the signature so it is guaranteed different.
let (payload, sig) = PROOF.split_once('.').unwrap();
let mut chars: Vec<char> = sig.chars().collect();
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify(&v, &headers_with(&tampered), SUB, IAT).is_err());
}
#[test]
fn rejects_missing_header() {
let v = verifier(3);
let err = verify(&v, &HeaderMap::new(), SUB, IAT).unwrap_err();
assert!(err.contains("missing"), "{err}");
}
#[test]
fn parse_mode_accepts_documented_values_and_rejects_junk() {
assert_eq!(parse_mode(""), Some(Mode::Off));
assert_eq!(parse_mode("off"), Some(Mode::Off));
assert_eq!(parse_mode(" Shadow "), Some(Mode::Shadow));
assert_eq!(parse_mode("ENFORCE"), Some(Mode::Enforce));
// Typos must NOT silently disable the gate.
assert_eq!(parse_mode("enforced"), None);
assert_eq!(parse_mode("on"), None);
}
#[test]
fn off_mode_allows_everything() {
let mut v = verifier(3);
v.mode = Mode::Off;
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_without_key_stays_inert() {
// iCaptcha unreachable at startup -> no key -> must not reject.
let v = Verifier {
mode: Mode::Enforce,
url: "https://icaptcha.gitlawb.com".to_string(),
required_level: 3,
key: None,
};
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_with_key_rejects_missing_proof() {
let v = verifier(3);
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Reject(_)
));
}
#[test]
fn shadow_allows_despite_bad_proof() {
let mut v = verifier(3);
v.mode = Mode::Shadow;
assert!(matches!(
decide(&v, &HeaderMap::new(), SUB, IAT),
Decision::Allow
));
}
#[test]
fn enforce_valid_proof_requires_consuming_its_jti() {
// A verified proof under enforce must yield Consume carrying the jti, so
// the caller can spend it once and reject replays.
let v = verifier(3);
match decide(&v, &headers_with(PROOF), SUB, IAT) {
Decision::Consume(p) => {
assert_eq!(p.jti, "4b5228a5bed7122dee9f47ff");
assert_eq!(p.exp, 1782573151);
assert_eq!(p.token, PROOF, "the raw token is retained for persistence");
assert_eq!(p.sub, SUB);
}
other => panic!("expected Consume, got {other:?}"),
}
}
#[test]
fn propagation_accepts_an_expired_proof() {
// The mirror-admission path verifies sig/level/DID but NOT expiry, since a
// proof has usually expired by the time a repo propagates. The same proof
// that the direct path rejects as expired must verify here.
let v = verifier(3);
let now = 9_999_999_999; // far past the proof's exp
assert!(
verify_token(&v, PROOF, SUB, now, true).is_err(),
"direct path (check_exp=true) rejects the expired proof"
);
assert!(
verify_token(&v, PROOF, SUB, now, false).is_ok(),
"propagation path (check_exp=false) accepts it"
);
}
#[test]
fn propagation_still_enforces_signature_level_and_did() {
let v = verifier(3);
// wrong owner DID
assert!(verify_token(&v, PROOF, "did:key:zother", IAT, false).is_err());
// insufficient level
let v5 = verifier(5);
assert!(verify_token(&v5, PROOF, SUB, IAT, false).is_err());
// tampered signature
let (payload, sig) = PROOF.split_once('.').unwrap();
let mut chars: Vec<char> = sig.chars().collect();
chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
let tampered = format!("{}.{}", payload, chars.into_iter().collect::<String>());
assert!(verify_token(&v, &tampered, SUB, IAT, false).is_err());
}
}
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more