Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b3f56cf
test(node): add HTTP-signature replay guards (#253)
beardthelion Jul 27, 2026
48657ca
refactor(core): derive the Signature-Input component list from COVERE…
beardthelion Jul 27, 2026
ce839b5
fix(core): make the signature acceptance window one-sided (#253)
beardthelion Jul 27, 2026
cb7006a
feat(core): sign a per-request nonce into Signature-Input (#253)
beardthelion Jul 27, 2026
5e74cd6
feat(node): add the spent-signature ledger schema and accessor (#253)
beardthelion Jul 27, 2026
7852b88
feat(node): publish the verified signature identity for ledger keying…
beardthelion Jul 27, 2026
699ed99
fix(node): spend a verified signature once on mutation routes (#253)
beardthelion Jul 27, 2026
5c321bf
feat(node): add a staged GITLAWB_REQUIRE_SIGNATURE_NONCE flag (#253)
beardthelion Jul 27, 2026
fff2928
fix(gl): fail loudly on a spent-signature rejection instead of return…
beardthelion Jul 27, 2026
5a3b783
fix(node): renumber the ledger migration and fail loudly on a version…
beardthelion Jul 27, 2026
ea15e37
fix(node): brake the signed write routes per IP, and correct three cl…
beardthelion Jul 27, 2026
432d493
fix(gl): surface every signature denial, and stop trusting node prose…
beardthelion Jul 27, 2026
b635df8
fix(core): reject a reversed-parenthesis Signature-Input instead of p…
beardthelion Jul 27, 2026
701fa3a
fix(node): validate the nonce, key the cap on the resolved key, widen…
beardthelion Jul 27, 2026
7c01eef
fix(node): retry a peer's retryable sync-notify rejection, and log a …
beardthelion Jul 27, 2026
54dd466
feat(node): count every spent-signature ledger outcome (#253)
beardthelion Jul 27, 2026
7d1d6f8
fix(core,node,gl): make a forgotten denial code a compile error (#253)
beardthelion Jul 27, 2026
b48c62c
fix(node): tell the operator the truth when a migration version colli…
beardthelion Jul 27, 2026
467d46d
fix(node,gl): give the flood brake a code, and gate the retry on it (…
beardthelion Jul 27, 2026
793e721
fix(gl): cap and sanitize MCP json_ok denial bodies
beardthelion Aug 29, 2026
022931b
fix(node,gl): close review gaps on ledger, MCP, and peer notify
beardthelion Aug 29, 2026
f37f29d
merge: integrate origin/main into test/replay-guards-253
beardthelion Aug 29, 2026
8dd294b
fix(gl,node): make MCP git_refs advertisement-complete and unbrake si…
kevincodex1 Aug 31, 2026
d806794
Merge remote-tracking branch 'origin/main' into test/replay-guards-253
beardthelion Nov 12, 2026
dbf301e
fix(node,gl): sweep signed-write limiter keys, surface denial-body re…
beardthelion Nov 12, 2026
ea494bb
Address PR review feedback (#261)
beardthelion Nov 12, 2026
6ae89e0
test(node): disposition the restored gossip hysteresis fixture in the…
beardthelion Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# cannot push.
GITLAWB_ENFORCE_OWNER_PUSH=true

# Require a `nonce` parameter on signatures that authorize a write. Without one
# the replay ledger keys on the signing-string hash, which rejects a repeated
# byte-identical mutation inside the same second. Keep false until every client
# signs a nonce; with this and GITLAWB_REQUIRE_SIGNED_PEER_WRITES both true, a
# peer still on a pre-nonce binary can no longer write to this node.
GITLAWB_REQUIRE_SIGNATURE_NONCE=false

# Comma-separated libp2p multiaddrs.
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
GITLAWB_P2P_BOOTSTRAP=
Expand Down Expand Up @@ -365,6 +372,18 @@ GITLAWB_RATE_LIMIT_MAX=10
# limiter. Default 3600.
GITLAWB_RATE_LIMIT_WINDOW_SECS=3600

# ── Signed-write rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY) ────
# Max requests per client IP per hour on the write routes that require an HTTP
# Signature (tasks, PR merge/close/review/comment, webhooks, branch protection,
# stars, replicas, labels, visibility, agent deregistration, bounties, profile,
# issue close/comment). A request that passes the brake spends a
# consumed_signatures row before the handler checks ownership or existence, and
# any freshly minted did:key can sign, so this brake is what bounds the durable
# writes an unregistered caller can force. A request over the limit gets
# 429 rate_limited before signature verification and never touches the ledger. Separate bucket from the creation, push and
# peer-sync limits. 0 disables. Default 600.
GITLAWB_SIGNED_WRITE_RATE_LIMIT=600

# ── 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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ 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.

A verified signature is spent once on the write routes that require one: repo, issue, PR, task, bounty, profile, webhook, label, star, replica, visibility and agent mutations, plus git push and `POST /api/v1/sync/trigger`. A captured request to any of those cannot be replayed. The two peer-write routes are the exception. `POST /api/v1/peers/announce` and `POST /api/v1/sync/notify` reach the ledger only when `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true`; under the default (`false`) they verify a signature when the headers are present but never spend it, so a captured signed peer write is still replayable there until the fleet upgrades and the flag is turned on. The ledger keys on the RFC 9421 `nonce` parameter when the client signs one and falls back to the signing-string hash when it does not. The fallback is correct but coarser: a client that repeats a byte-identical mutation within the same second is rejected as a replay and has to re-sign. `GITLAWB_REQUIRE_SIGNATURE_NONCE` closes that fallback once every client emits a nonce:

```bash
GITLAWB_REQUIRE_SIGNATURE_NONCE=true
```

With it on, a nonce-less signature on a write route is rejected with 400 and `X-Gitlawb-Error: signature_nonce_required`. Signed reads are never affected. Flipping it on has a federation precondition: three of the node's own outbound signed calls (peer announce, replica registration, sync-notify) are peer traffic, so a node running with both this and `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` set to true will reject peer writes from any node still on a pre-nonce binary. Upgrade the fleet before enabling it.

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.
Expand Down Expand Up @@ -402,6 +410,9 @@ Important node settings:
| `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 signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
| `GITLAWB_REQUIRE_SIGNATURE_NONCE` | Require a signed `nonce` on write routes. Default false; see the staged rollout note above. |
| `GITLAWB_SIGNED_WRITE_RATE_LIMIT` | Per-client-IP requests per hour on the signed write routes (tasks, PR merge/close/review/comment, webhooks, branch protection, stars, replicas, labels, visibility, agent deregistration, bounties, profile, issue close/comment). A request that passes the brake spends a ledger row before the handler checks anything, so the brake bounds what an unregistered caller can write; a request over the limit gets `429 rate_limited` before signature verification and never touches the ledger. Own bucket; `0` disables. Default 600. |
<!-- verbatim -->
| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the pusher on `git-receive-pack` to be the repo owner, or to present an owner-rooted, time-bounded UCAN `git/push` capability naming the repository (`X-Ucan`). **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. A CI or delegated key with no such delegation counts as a non-owner and cannot push while this is on. The delegation clears the push gate only; on a private repository the delegate must also be a reader. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_RECONCILIATION_SWEEP` | Enable the hourly durability sweep that re-pins/backstops missing objects (default `true`; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (`GITLAWB_IPFS_API`); Pinata-only nodes reconcile public pins only. |
Expand Down
Loading
Loading