- BLAKE3 keyed proof-of-work on object storage and auth challenges, with frozen vectors cross-checked against an independent Python reference implementing the single-block hash it needs. - Sparse Merkle trie over object IDs: order-independent roots, inclusion and absence proofs (internal/smt). - Signed checkpoint chain per relay: transport key amendment to INV-1, /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe epoch continuity (internal/checkpoint). - Head gossip with TOFU pinning and equivocation detection; light node (cmd/lightnode) that stores no history: quorum of pinned relays, every served object proven against the agreed root, LRU disk cache. - WebSocket streaming on relay and light node (coder/websocket): scoped channels mirroring REST, raw envelopes verified client-side; light node marks streamed objects unproven until checkpoint coverage. - Protocol v1 additions: DelegationClaim tag 0x07 with deterministic chain resolution in verify.Graph, KeyRotationRequest/Confirm tags 0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge; spec sections, frozen vectors appended byte-identically, Python reference extended. - Optional permissioned BFT finality over gossip (internal/bft): prevote/precommit with quorum certificates verifiable offline. - Quick wins: Policy.TrustedIssuers, per-type stored metrics, batch fetch, lexicographic lists with stable cursor pagination. - Security review of the network layer (docs/SECURITY-REVIEW.md) with findings F-01..F-09; hub send/close race and unstable pagination fixed under review. 12 packages green, vet/gofmt clean, protocol fuzzing stable.
4.1 KiB
Proof-of-work admission control
This document specifies the relay's anti-abuse mechanism: a BLAKE3 proof-of-work that anonymous clients must pay before the relay does work for them. It is transport-layer only. It never enters TCE bytes, never affects a signature, and is invisible to verifiers: PROTOCOL.md §9 keeps server-side data out of signed statements, and this mechanism is server-side data.
Companion documents: API.md, TRUST-MODEL.md.
Frozen vectors: testdata/vectors/pow_vectors.json, generated by
tools/reference/pow_reference.py and reproduced byte-for-byte by the Go test
suite.
1. Why
The relay is an open bulletin board. Two cheap defenses already exist — body caps and per-IP rate limits — but a botnet behind many IPs defeats IP limits, and honest low-volume clients are exactly the traffic worth keeping. A proof of work taxes submission per attempt, not per address: spam stops being free, while a real service posting a claim pays well under a second of hashing.
2. Scheme
sum = BLAKE3_keyed(key, DOMAIN || target || counter_be)
valid ⟺ leading_zero_bits(sum) ≥ difficulty
DOMAIN = "trust.n1ko.dev/pow/1"
key = 32 bytes from the relay's CSPRNG, issued once
target = object content ID for storage; 32 zero bytes for authentication
counter = unsigned 32-bit integer, big-endian on the wire
Properties:
- Verification is one hash call. The asymmetry between solver and checker is total.
- The keyed mode matters. The hash input includes a fresh server-chosen key, so solutions cannot be precomputed, pooled across relays, or reused after a challenge is consumed.
- The target binds storage proofs to one submission. A captured but unspent challenge only helps an attacker store the exact object its victim was going to store.
- Single use. A challenge key is deleted when first presented, whether or
not the proof verifies. Replay of a captured
{key, counter}pair fails. - Difficulty is in leading zero bits, capped at 30: solving ranges over a uint32 counter, and beyond 30 bits the counter space no longer guarantees a solution exists.
3. Endpoints and flow
Storage (POST /v1/objects)
- Client builds and signs the object locally;
object_id = SHA-256(tce). POST /v1/pow/challengewith body{"purpose": "put"}→{ "key", "difficulty", "ttl" }.- Client solves for
(key, target=object_id, difficulty)→counter. POST /v1/objectswith the envelope plus"pow": { "key": "<hex>", "counter": <uint> }.
Authentication (POST /v1/auth/challenge)
Identical, with {"purpose": "auth"} and target = 32 zero bytes; the solved
proof accompanies the request body as "pow". Binding to nothing beyond the
fresh key is intentional here: single-use consumption carries the protection.
Defaults
| Tier | Flag | Default |
|---|---|---|
| storage | -pow-put-bits |
22 (~4M hashes; well under a second on desktop, seconds on a phone) |
| authentication | -pow-auth-bits |
18 |
0 disables a tier. Values above 30 are clamped. Challenge issuance itself is
additionally capped at 30/min per IP.
4. Failure modes
| Situation | Response |
|---|---|
tier enabled, no pow field |
429 proof of work required |
| malformed key hex | 429 malformed proof of work |
| unknown / expired / already-spent key | 429 unknown or expired challenge |
| hash misses the target | 429 invalid proof of work |
| challenge endpoint over rate limit | 429 rate limited |
A spent-but-invalid challenge is still consumed: guessing counters must not get free retries against one key.
5. Reference implementations
- Go:
internal/pow—Sum,Verify,Solve,LeadingZeroBits. - Python reference (independent, implements the single-block BLAKE3 it needs
from scratch):
tools/reference/pow_reference.py. - Frozen vectors: 6 accept cases (including the zero-target authentication binding, up to difficulty 16) with minimal recorded counters, plus rejects (off-by-one counter, wrong key) and configuration rejects (difficulty > 30).
Any implementation agreeing with both files agrees with the specification.