Peering handshake: Ed25519 challenge-response + capability tokens (#102, part 1) #121

Merged
Cordy merged 7 commits from feat/peering-handshake into main 2026-08-05 00:27:04 +00:00
Owner

First slice of #102 — the security-critical pieces, built test-first. No gRPC yet, so go.mod is unchanged and #118's tripwire stays unpulled; the proto, service and data plane follow in part 2.

A design defect in the decided ADR, found by writing the test

IMPLEMENTED-PEERING.md §3.1 specified the proof as HMAC-SHA256(peerKey, …) while §4.1 required the receiver to store only a verifier. Those cannot both hold: verifying an HMAC needs the shared secret, so the receiver would have had to keep peer keys in the clear — destroying the exact property the directed-pair model exists for ("compromising this instance yields verifiers, not credentials").

Corrected: the issued key is an Ed25519 private key. The peer holds and presents it; we store only the public half plus a lookup id; the proof is a signature over the challenge. Compromising a receiver now yields public keys — useless anywhere. Same UX (one opaque string exchanged out of band), no new dependency (crypto/ed25519 is stdlib). The reasoning is recorded at the top of auth.go so the doc and the code don't diverge silently.

This changes #101's stored format (HashID + PublicKey). Nothing is deployed, so there is no migration.

What's here

  • KeyIDOf — both sides derive the same lookup id from the key (SHA-256 prefix over the public half), so the receiver selects the right key without trial verification.
  • NewChallenge / Prove / VerifyProof — 32-byte single-use challenges with a 2-minute TTL and opportunistic sweep. The challenge is the anti-replay state, so no separate nonce cache. Signatures are bound to the key id, so a proof cannot be replayed under another identity. An unknown key id still gets a well-formed challenge: Hello must not be an oracle for which peers exist. Challenges are spent whether or not the proof verifies, so a captured challenge can't be brute-forced.
  • Capability tokensMintCapability / VerifyCapability: HMAC over transfer id, accepted size and expiry, keyed by a lazily-minted per-instance secret. Single-use, bound to their transfer, expiring, and unverifiable against another instance.

Tests (13 new, watched fail first)

Stored state contains no secret · id derivable by the sender and matching the stored one · malformed key rejected · challenge round-trip · challenge single-use · proof signed by another key rejected · proof bound to another challenge rejected · revoked key cannot authenticate · unknown key id still gets a challenge · capability round-trip · capability single-use · capability bound to its transfer · capability expires · capability minted elsewhere rejected.

go vet clean, go build clean, full repo suite green.

Next (part 2): proto + committed codegen, the four RPCs, the data-plane PUT, wiring, and #118's doc corrections in that same PR.

First slice of #102 — the security-critical pieces, built test-first. **No gRPC yet**, so `go.mod` is unchanged and #118's tripwire stays unpulled; the proto, service and data plane follow in part 2. ## A design defect in the decided ADR, found by writing the test `IMPLEMENTED-PEERING.md` §3.1 specified the proof as `HMAC-SHA256(peerKey, …)` while §4.1 required the receiver to store **only a verifier**. Those cannot both hold: verifying an HMAC needs the shared secret, so the receiver would have had to keep peer keys in the clear — destroying the exact property the directed-pair model exists for ("compromising this instance yields verifiers, not credentials"). **Corrected:** the issued key is an **Ed25519 private key**. The peer holds and presents it; we store only the public half plus a lookup id; the proof is a signature over the challenge. Compromising a receiver now yields public keys — useless anywhere. Same UX (one opaque string exchanged out of band), no new dependency (`crypto/ed25519` is stdlib). The reasoning is recorded at the top of `auth.go` so the doc and the code don't diverge silently. This changes #101's stored format (`Hash` → `ID` + `PublicKey`). Nothing is deployed, so there is no migration. ## What's here - **`KeyIDOf`** — both sides derive the same lookup id from the key (SHA-256 prefix over the public half), so the receiver selects the right key without trial verification. - **`NewChallenge` / `Prove` / `VerifyProof`** — 32-byte single-use challenges with a 2-minute TTL and opportunistic sweep. The challenge *is* the anti-replay state, so no separate nonce cache. Signatures are bound to the key id, so a proof cannot be replayed under another identity. An unknown key id still gets a well-formed challenge: `Hello` must not be an oracle for which peers exist. Challenges are spent whether or not the proof verifies, so a captured challenge can't be brute-forced. - **Capability tokens** — `MintCapability` / `VerifyCapability`: HMAC over transfer id, accepted size and expiry, keyed by a lazily-minted per-instance secret. Single-use, bound to their transfer, expiring, and unverifiable against another instance. ## Tests (13 new, watched fail first) Stored state contains no secret · id derivable by the sender and matching the stored one · malformed key rejected · challenge round-trip · **challenge single-use** · proof signed by another key rejected · proof bound to another challenge rejected · revoked key cannot authenticate · unknown key id still gets a challenge · capability round-trip · capability single-use · capability bound to its transfer · capability expires · capability minted elsewhere rejected. `go vet` clean, `go build` clean, full repo suite green. **Next (part 2):** proto + committed codegen, the four RPCs, the data-plane PUT, wiring, and #118's doc corrections in that same PR.
Cordy merged commit 09581fd64c into main 2026-08-05 00:27:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#121
No description provided.