refactor: drop PoW, checkpoint chain, gossip, light node and BFT; keep WS

The decentralization stack overcomplicated the project. Removed:
internal/pow, internal/smt, internal/checkpoint, internal/bft,
internal/lightnode, cmd/lightnode, relay gossip/checkpoint/proof/BFT
endpoints, their docs, vectors and the blake3 dependency.

Kept: WebSocket streaming on the relay, the full protocol v1 object set
including DelegationClaim (0x07) and KeyRotation request/confirm
(0x08/0x09) with chain resolution in verify.Graph, TrustedIssuers,
batch fetch, stable cursor pagination, per-type metrics.

INV-1 reverts to its original form: the relay holds no keys again.
Everything removed remains reachable at commit 20cc52c.
This commit is contained in:
Niko Marmeladkov 2026-08-26 00:38:45 +03:00
parent b5c0200491
commit e23a3cab59
41 changed files with 53 additions and 7213 deletions

View file

@ -1,94 +0,0 @@
// Command lightnode runs a verifying mirror of the relay API that stores no
// history. It follows signed checkpoint heads from its peers, requires a
// quorum of relay keys to agree on one root, and serves objects fetched on
// demand — each one proven against the agreed set before it is cached or
// returned.
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
)
func main() {
addr := flag.String("addr", ":8090", "listen address")
peers := flag.String("peers", "", "comma-separated relay base URLs (required)")
pinKeys := flag.String("pin-keys", "", "comma-separated hex relay public keys; empty = trust-on-first-use")
quorum := flag.Int("quorum", 0, "relay keys required to agree on one root (0 = all peers)")
cacheDir := flag.String("cache-dir", "", "directory for fetched objects (empty = memory only)")
cacheMaxMB := flag.Int64("cache-max-mb", 256, "cache size cap in MiB")
interval := flag.Duration("refresh-interval", 10*time.Second, "checkpoint polling interval")
stream := flag.Bool("stream", false, "mirror upstream WebSocket streams to local clients (GET /v1/ws)")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if *peers == "" {
logger.Error("-peers is required")
os.Exit(2)
}
node := lightnode.New(lightnode.Config{
Peers: strings.Split(*peers, ","),
PinKeys: splitNonEmpty(*pinKeys),
Quorum: *quorum,
CacheDir: *cacheDir,
CacheMaxBytes: *cacheMaxMB << 20,
})
ctx, stop := context.WithCancel(context.Background())
defer stop()
go node.Run(ctx, *interval)
if *stream {
wsn, err := node.WS()
if err != nil {
logger.Error("websocket subsystem unavailable", "err", err)
os.Exit(1)
}
go wsn.RunWS(ctx)
}
h := &http.Server{
Addr: *addr,
Handler: node.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("light node listening", "addr", *addr, "peers", *peers)
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("listen", "err", err)
os.Exit(1)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
stop()
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = h.Shutdown(ctx2)
logger.Info("stopped")
}
func splitNonEmpty(s string) []string {
if s == "" {
return nil
}
var out []string
for _, part := range strings.Split(s, ",") {
if part != "" {
out = append(out, part)
}
}
return out
}

View file

@ -12,7 +12,6 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@ -24,13 +23,6 @@ func main() {
addr := flag.String("addr", "", "listen address (overrides config)")
audience := flag.String("audience", "", "server audience bound into auth assertions (overrides config)")
data := flag.String("data", "", "directory to persist objects (overrides config)")
powPutBits := flag.Int("pow-put-bits", 22, "proof-of-work difficulty for storing objects, leading zero bits (0 = off)")
powAuthBits := flag.Int("pow-auth-bits", 18, "proof-of-work difficulty for auth challenge issuance, leading zero bits (0 = off)")
ckptInterval := flag.Duration("ckpt-interval", time.Minute, "maximum time between signed checkpoints when the object set changed")
ckptEvery := flag.Int("ckpt-every", 128, "sign a checkpoint after this many new objects (0 = interval only)")
bftValidators := flag.String("bft-validators", "", "comma-separated validator public keys (hex); enables BFT finality")
bftURLs := flag.String("bft-urls", "", "comma-separated validator base URLs, aligned with keys")
bftTimeout := flag.Duration("bft-round-timeout", 2*time.Second, "BFT round timeout")
flag.Parse()
cfg, err := server.LoadConfig(*configPath)
@ -56,22 +48,8 @@ func main() {
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel(cfg.LogLevel)}))
srv := server.New(cfg,
server.WithPow(*powPutBits, *powAuthBits),
server.WithCheckpoints(cfg.DataDir, server.CheckpointConfig{Interval: *ckptInterval, EveryN: *ckptEvery}),
)
if *bftValidators != "" && *bftURLs != "" {
srv.SetBFT(server.BFTConfig{
ValidatorKeys: splitCSV(*bftValidators),
ValidatorURLs: splitCSV(*bftURLs),
RoundTimeout: *bftTimeout,
})
}
srv := server.New(cfg)
ctx, stop := context.WithCancel(context.Background())
srv.StartCheckpoints(ctx)
srv.StartBFT(ctx)
defer stop()
h := &http.Server{
Addr: cfg.ListenAddr,
Handler: srv.Handler(),
@ -80,7 +58,7 @@ func main() {
go func() {
logger.Info("trust relay listening", "addr", cfg.ListenAddr, "audience", cfg.Audience, "data_dir", cfg.DataDir)
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := h.ListenAndServe(); err != nil && http.ErrServerClosed != err {
logger.Error("listen", "err", err)
os.Exit(1)
}
@ -89,7 +67,6 @@ func main() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
stop()
logger.Info("shutting down")
ctx2, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -100,16 +77,6 @@ func main() {
logger.Info("stopped")
}
func splitCSV(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if part != "" {
out = append(out, part)
}
}
return out
}
func logLevel(s string) slog.Level {
var l slog.Level
if err := l.UnmarshalText([]byte(s)); err != nil {

View file

@ -9,14 +9,10 @@ Read endpoints require a session token. Obtain one with the challenge/assert
handshake (AuthAssertion bound to the server's `audience`):
```
POST /v1/pow/challenge {"purpose":"auth"} -> { "key", "difficulty", "ttl" }
POST /v1/auth/challenge ({"pow":{...}}) -> { "challenge": "<hex>" }
POST /v1/auth/challenge -> { "challenge": "<hex>" }
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
```
When `-pow-auth-bits` is enabled, `/v1/auth/challenge` requires a solved
challenge first; see [POW.md](POW.md).
Send the token as `Authorization: Bearer <token>` or `?token=<token>`. A session
is valid for 30 minutes; each challenge is single-use and expires after 5
minutes. Scopes: `read:claims`, `read:requests`, `read:responses`,
@ -28,22 +24,15 @@ Rate limit: `POST /v1/auth/challenge` is capped at **30/min per IP** (`429`).
| Method | Path | Auth | Query / body | Purpose |
|--------|--------------------------|-------------|-----------------------------|------------------------------------------|
| POST | `/v1/objects` | none | `Envelope` + optional `pow` | Store a signed object (claim/request/...) |
| POST | `/v1/objects` | none | `Envelope` body | Store a signed object (claim/request/...) |
| GET | `/v1/objects/{id}` | none | — | Fetch one object by content id |
| GET | `/v1/objects?ids=a,b,c` | none | ≤100 ids | Batch fetch; missing ids are omitted |
| POST | `/v1/pow/challenge` | none | `{"purpose":"put"\|"auth"}` | Issue a single-use PoW challenge key |
| GET | `/v1/claims` | session | `subject`, `limit`,`offset` | Claims about a subject |
| GET | `/v1/requests` | session | `recipient`,`limit`,`offset`| Approval requests to a recipient |
| GET | `/v1/responses` | session | `request`,`limit`,`offset` | Responses to a request |
| GET | `/v1/revocations` | session | `claim`,`limit`,`offset` | Revocations targeting a claim |
| GET | `/v1/config` | none | — | `{ "audience": "...", "relay_pubkey": "..." }` |
| GET | `/v1/config` | none | — | `{ "audience": "..." }` |
| GET | `/v1/metrics` | none | — | Prometheus-style metrics |
| GET | `/v1/checkpoint/latest` | none | — | Newest signed object-set commitment |
| GET | `/v1/checkpoint/{epoch}` | none | — | A specific historical head |
| GET | `/v1/proof/object/{id}` | none | — | Inclusion proof against the current root |
| GET | `/v1/proof/absent/{id}` | none | — | Absence proof against the current root |
| POST | `/v1/gossip/checkpoint` | none | peer head announcement | Record another relay's signed head |
| GET | `/v1/peers/heads` | none | — | Observed peer heads |
| GET | `/v1/ws` | session | WebSocket control frames | Live stream of newly stored envelopes |
| GET | `/v1/healthz` | none | — | Liveness (`200`) |
| GET | `/v1/readyz` | none | — | Readiness (`200` / `503`) |
@ -76,8 +65,6 @@ if you had fetched it yourself. Limits: ≤16 subscriptions per connection;
a client that cannot keep up is disconnected rather than allowed to stall
broadcasts; the session's 30-minute lifetime applies mid-stream.
Checkpoint and proof endpoints are specified in
[CHECKPOINT.md](CHECKPOINT.md); the proof-of-work scheme in [POW.md](POW.md).
## Storing objects (`POST /v1/objects`)
@ -96,16 +83,13 @@ Checkpoint and proof endpoints are specified in
Request / response shape:
```
POST { "tce": "<base64>", "signature": "<base64>",
"pow": { "key": "<hex>", "counter": <uint> } }
POST { "tce": "<base64>", "signature": "<base64>" }
200 { "object_id": "<content-id>" }
422 { "error": "server: subject ... quota exceeded" }
413 { "error": "payload too large" }
429 { "error": "proof of work required" | "invalid proof of work" | "rate limited" }
429 { "error": "rate limited" }
```
The `pow` field is required when `-pow-put-bits` > 0 (the default); see
[POW.md](POW.md) for the scheme.
## Listing endpoints (claims/requests/responses/revocations)
@ -131,7 +115,8 @@ decode the `TCE` to read the issuer/subject/fields.
- `GET /v1/metrics``text/plain` Prometheus exposition with at least:
`trust_objects_stored`, `trust_objects_rejected`, `trust_challenges_issued`,
`trust_assertions_ok`, `trust_assertions_failed`, `trust_sessions_active`,
`trust_pow_ok`, `trust_pow_failed`.
`trust_ws_connections`, `trust_ws_messages_sent`, and one
`trust_objects_stored_<type>` counter per object type.
## Status code summary

View file

@ -1,78 +0,0 @@
# BFT finality over checkpoint gossip
Optional permissioned finality layered on the checkpoint chain. Gossip alone
detects divergence after the fact; a validator set prevents it from being
presented as truth in the first place: a height commits to exactly one head,
and everyone can check that commitment offline.
---
## 1. Roles
- **Validators** are relays whose transport key doubles as their validator
key. One key, one role, no new secrets. The set is fixed in configuration
(`-bft-validators`, `-bft-urls`) — membership changes are an operational
ceremony, not a protocol event.
- **Everyone else** (relays, light nodes, verifiers) needs only the public
validator list to check a certificate.
## 2. Protocol
Per height `h` (aligned with checkpoint epochs), deterministic leader:
```
proposer(h, r) = validators[ SHA-256(height‖round)[0] mod n ]
```
1. The round's proposer offers its latest head.
2. Validators **prevote** for the proposed head (or nothing).
3. On a quorum prevote for X, each validator **precommits** X.
4. On a quorum precommit for X the height finalizes with a *certificate*:
the collected precommit signatures. Height advances; the finalized head
becomes the anchor every future proposal must chain onto.
Quorum is `2f+1` of `n` with tolerated Byzantine validators `f = ⌊(n1)/3⌋`.
A stalled or equivocating proposer costs one round timeout, then leadership
rotates. All messages are canonical bytes signed with Ed25519; phases live
in the signing domain, so a prevote can never be replayed as a precommit.
## 3. Guarantees and simplifications
Safety: two certificates for one height require ≥⅓ Byzantine validators.
Liveness: finality proceeds while ≥ quorum validators are online and honest;
a minority outage stalls that height until they return (fail-stop, never
fork).
Documented v1 simplifications, none safety-relevant:
- Single in-flight round per height on the driver; rotation happens through
timeouts rather than full Tendermint lock-rules.
- Validators accept a proposal whose head they have already verified via
gossip; head-body re-fetch before prevoting is future work.
- Validator-set changes are manual: update config, restart.
## 4. Endpoints
| Method | Path | Purpose |
|--------|-------------------------------|--------------------------------------------|
| POST | `/v1/bft/proposal` | validator fan-out: signed proposal |
| POST | `/v1/bft/vote` | validator fan-out: signed prevote/precommit|
| GET | `/v1/bft/state` | `{enabled, height, last_finalized}` |
| GET | `/v1/bft/certificate/{h}` | finality certificate for a height |
Light nodes pin the validator keys (`BFTValidators`); `/v1/bft/certificate`
then serves only certificates that verify against exactly those keys, so a
light node can demand "quorum-agreed root" instead of trusting any single
mirror's checkpoint.
## 5. Composition
```
relays ──gossip──▶ heads ──▶ validators vote ──▶ certificate
light nodes ◀── verify offline ──────────────┘
```
Gossip remains the data plane; BFT is a thin consensus overlay on which head
counts as canon per height. Disabling it returns the system to plain
checkpoint-gossip with split-view detection by comparison.

View file

@ -1,105 +0,0 @@
# Checkpoints, proofs and light nodes
This document specifies the relay's signed commitment to its own object set,
the proof endpoints built on it, and the light node that consumes both. It is
the mechanism that turns the INV-1 caveat ("a relay can withhold anything")
from an act of faith into a detectable event.
Companion documents: [TRUST-MODEL.md](TRUST-MODEL.md),
[API.md](API.md), [PROTOCOL.md](PROTOCOL.md).
---
## 1. The idea
A relay stores a set of content-addressed objects. The sparse Merkle root of
that set (`internal/smt`) is a **function of the set alone**: two relays that
store the same objects compute the same root regardless of insertion order or
restart history. A relay signs `{epoch, size, root, prev, created_at}` every
interval or every N new objects; the `prev` field chains each head to the
hash of its predecessor, making per-relay history tamper-evident without any
global consensus.
Decentralization falls out of gossiping heads: mirrors that disagree about
what a relay stores are detectable by comparing what independent peers claim
under the same relay key. No token, no mining, no block cadence — approval
flows keep their 60-second lifetime, and phones can participate as light
nodes instead of miners.
## 2. What the relay key may and may not do
INV-1 amendment: the relay holds exactly one key. Its entire power is to
describe its own storage — signing checkpoints and nothing else. Statement
signing still lives exclusively in `internal/identity/signer`, which server
code cannot import (enforced by `TestServerDoesNotImportSigner`), and the
checkpoint package never touches protocol objects. Both properties are
source-scanned by `TestNoSigningOutsideSigner`.
The seed persists in `<data-dir>/relay_key.seed` (0600). In-memory mode
generates an ephemeral key per process; heads are then valid only while the
process lives.
## 3. Trie and proofs
Hash domain (SHA-256):
```
leaf = H(0x00 || object_id)
branch = H(0x01 || uvarint(prefixLen) || prefixBits || left || right)
empty = H(0x02)
```
The branch prefix is inside the hash so trie shape is committed. Branches
split exactly where their keys first differ, so shape is canonical.
- **Inclusion proof** (`GET /v1/proof/object/{id}`): sibling path from the
leaf to the root. Verifies only for the exact object id.
- **Absence proof** (`GET /v1/proof/absent/{id}`): witnesses the spot where
the id would live — either the neighbouring leaf reached by walking the
id's bits, or the branch whose prefix the id leaves mid-way. This is what
lets a consumer prove "nothing else about this subject exists" rather than
trusting a relay's silence.
Proofs verify against a root the consumer obtained independently (from a
quorum of peers), never against a root the serving relay just made up.
## 4. Endpoints
| Method | Path | Purpose |
|--------|---------------------------|------------------------------------------------|
| GET | `/v1/checkpoint/latest` | newest signed head `{bytes, signature, id, public_key, checkpoint}` |
| GET | `/v1/checkpoint/{epoch}` | a specific historical head |
| GET | `/v1/proof/object/{id}` | inclusion proof against the current root |
| GET | `/v1/proof/absent/{id}` | absence proof against the current root |
| POST | `/v1/gossip/checkpoint` | announce a peer's signed head |
| GET | `/v1/peers/heads` | observed peer heads |
`bytes` is authoritative (base64); the decoded `checkpoint` view is a
convenience, mirroring how envelopes work. Gossip accepts a head only if the
signature verifies under the announcing key and the id matches the bytes;
same-key/same-epoch/different-bytes is recorded as divergence
(`trust_gossip_divergence`) and exposed, never silently resolved.
Configuration flags: `-ckpt-interval` (default 60s), `-ckpt-every`
(default 128 new objects).
## 5. Light nodes
`cmd/lightnode` follows heads from `-peers`, requires `-quorum` distinct
relay keys to agree on one root (TOFU pinning unless `-pin-keys` given), then
serves the read API locally:
- every served object was proven present in the agreed set before caching;
- every absence claim was proven against the agreed root;
- storage is the working set plus heads — no history;
- cached objects keep serving after all peers disappear.
Run it against two or more independent relays holding the same data:
```
lightnode -peers https://a.example,https://b.example \
-quorum 2 -cache-dir /var/lib/lightnode -addr :8090
```
The node makes no authorization decisions; consumers verify statements
locally as before (INV-5). Trust your own verification stack, not the mirror.

View file

@ -1,100 +0,0 @@
# 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](API.md), [TRUST-MODEL.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`)
1. Client builds and signs the object locally; `object_id = SHA-256(tce)`.
2. `POST /v1/pow/challenge` with body `{"purpose": "put"}`
`{ "key", "difficulty", "ttl" }`.
3. Client solves for `(key, target=object_id, difficulty)``counter`.
4. `POST /v1/objects` with 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.

View file

@ -1,104 +0,0 @@
# Security review — network layer
Review of everything that talks: TCE codec, protocol objects (including
delegation and rotation), proof-of-work, relay HTTP surface, WebSocket
streams, checkpoint chain and SMT proofs, gossip, light node, BFT overlay.
Method: threat-model walk per component plus targeted code reading; findings
were either fixed in this tree or are listed open with mitigations. Dates
and line references drift; component names are stable anchors.
---
## 1. Scope and trust anchors
| Actor | Trusts | Never trusts |
|---|---|---|
| Verifier | its own `TrustedIssuers` roots | relays, mirrors, other issuers |
| Relay | nothing | anyone (stores unsigned garbage by design) |
| Light node | pinned relay keys, quorum of them | any single mirror |
| BFT height | ≥⅔ validators honest | any minority |
The system's core bet is unchanged since v1: signatures over canonical bytes,
content addressing, and local verification. Everything added recently — PoW,
checkpoints, proofs, streaming, BFT — is transport or integrity plumbing and
was checked against one question: *can it mint, move, or hide a statement?*
## 2. Findings
| ID | Severity | Component | Summary | Status |
|----|----------|-----------|---------|--------|
| F-01 | High→fixed | WS hubs (relay, lightnode) | broadcaster could send on a closed channel (panic DoS) | **fixed**: close-once `done` under hub lock; broadcast skips evicted |
| F-02 | High→fixed | lists/pagination | offset pagination walked map-order results: unstable pages could reorder "facts" between fetches | **fixed**: lexicographic sort by content ID + `after` cursor |
| F-03 | Medium | auth tokens | bearer token accepted via `?token=` (needed for browser WS); URLs leak into logs and proxies | open — mitigation below |
| F-04 | Medium | gossip pinning | relay-side peer heads use TOFU: first contact pins the key, so a first-contact MITM poisons the view | open — treat `/v1/peers/heads` as advisory; pin lightnode keys out-of-band |
| F-05 | Medium | rotation | `Policy.RotationMaxAge` defaults to 0 = confirmed links never go stale | open — set it in production policies (recommend ≤ 90 days) |
| F-06 | Low | BFT driver | timeout round uses wall-clock nano mod n: honest validators may rotate rounds differently → delayed finality, never forks (votes are keyed by their own round) | open — proper lock-rules are future work |
| F-07 | Low | scopes | read scopes are self-asserted by the asserting key (`*` included): any keyholder may read list endpoints | accepted by design; write paths are the guarded ones |
| F-08 | Low | lightnode streams | streamed objects forwarded as `verified:false` before checkpoint coverage | documented trade-off; `/v1/objects/{id}` remains the evidence path |
| F-09 | Info | BFT set | membership changes are config + restart ceremonies | documented |
## 3. Component notes
### Codec and statements
Strict decoding fails closed on every ambiguity (unknown tags, non-minimal
uvarints, duplicate/unsorted maps, trailing bytes) — the property that makes
"the signed bytes are the meaning" hold. Delegation predicates are exact-match
by construction, which closes prefix-escalation (`user.` covering
`users.admin`) at the format level rather than by reviewer discipline.
Rotation needs two signatures bound by content hash, so neither side can
fabricate consent. Constant-time comparisons cover hashes, audiences,
responders, granters/predecessors.
### Proof-of-work
Keyed BLAKE3 binds each solution to a fresh server key and (for storage) to
the exact object ID; challenges are single-use even on failure paths, so
counter-guessing buys nothing. Difficulty is static per tier by operator
choice — adaptive difficulty would help under sustained botnets and is the
main lever left unused.
### Checkpoints, SMT proofs, light node
Roots are functions of the stored set, so mirrors agree or visibly diverge;
inclusion and absence proofs verify against a root obtained independently of
the serving mirror. The light node forwards REST objects only behind a proof
against the quorum-agreed root — the streaming exception is F-08 and is
labelled, not silent. Cache eviction is mtime-based; cache poisoning would
require a proof-validating forgery first.
### WebSocket
Both hubs now share the safe pattern: eviction flips an `evicted` flag and
closes a signal channel under the same mutex the broadcaster holds, so a
send can never race a close. Slow clients get dropped instead of stalling
fan-out; subscriptions are capped per connection; sessions expire mid-stream.
F-03 is the remaining soft spot: prefer headers over query tokens wherever
the client is not a browser.
### Gossip and BFT
Heads are accepted only with a valid signature from the announcing key and a
matching content hash; same-key/same-epoch/different-bytes raises the
divergence counter instead of being averaged away. Certificates are verified
offline against the full validator set: distinct voters, authentic signatures,
one head, quorum. The known limitations (F-06, F-09) cost liveness
convenience, not the two-certificate impossibility argument.
## 4. Deployment checklist
1. Terminate TLS at the relay or run loopback-only; every trust statement in
this document assumes an authenticated channel to the peer you meant.
2. Pin light-node relay keys and BFT validator keys in configuration; do not
rely on TOFU anywhere that matters (F-04).
3. Set `Policy.RotationMaxAge` in consumer policies (F-05).
4. Run ≥4 validators spread across networks; monitor
`trust_gossip_divergence`, `trust_pow_failed`, `trust_checkpoints_signed`
gaps, and BFT state height stalls.
5. Keep PoW tiers on for public writes; raise `-pow-put-bits` under attack
before touching IP limits.
6. Prefer `Authorization` headers over `?token=` for anything that is not a
browser (F-03).
## 5. What this review changed
F-01 and F-02 were real defects found while preparing this review and are
fixed with regression coverage (streaming tests through the light node, the
cursor-pagination test). F-03/F-04/F-05/F-06 remain open by design choice,
each with the mitigation above; none breaks the statement-integrity model —
they are availability and operational-hygiene issues, not forgery paths.

View file

@ -66,18 +66,6 @@ content-addressed bulletin board:
Consequence: **signature verification is the verifier's job, not the server's.**
A `PUT` with a tampered signature is stored; `verify.Graph.Add` rejects it.
### The relay's one key (INV-1 amendment)
The relay holds exactly one key, whose entire power is to describe its own
storage: signing the checkpoint chain that commits to its object set
([CHECKPOINT.md](CHECKPOINT.md)). That transport key can never mint a claim,
approval or any other statement — statement signing lives in
`internal/identity/signer`, which server code is structurally barred from
importing, and both boundaries are enforced by source-scanning invariant tests.
A hostile relay can therefore still lie about *its own* log; it remains unable
to forge what anyone said. Light nodes and gossip exist precisely to make such
lies detectable rather than merely suspected.
## Who do you believe? (issuer anchoring)
Because any key can sign a claim about any subject, the *verifier* must decide

3
go.mod
View file

@ -7,7 +7,4 @@ require (
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/coder/websocket v1.8.15
gopkg.in/yaml.v3 v3.0.1
lukechampine.com/blake3 v1.4.1
)
require github.com/klauspost/cpuid/v2 v2.0.9 // indirect

4
go.sum
View file

@ -51,8 +51,6 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -111,5 +109,3 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=

View file

@ -1,347 +0,0 @@
// Package bft implements permissioned Byzantine-fault-tolerant finality for
// the checkpoint chain.
//
// Model. A fixed set of relays act as validators (their signing key is the
// same transport key that signs their checkpoints). For every height they
// agree on exactly one head through two voting phases, prevote then
// precommit, requiring a quorum of distinct validators in each. A height
// finalizes with a certificate: quorum precommits over the same canonical
// bytes. Anyone holding the validator set can verify a certificate offline —
// running a validator is not required to check finality.
//
// Safety: two conflicting certificates for one height imply ≥⅓ Byzantine
// validators (standard two-phase argument), because a precommit for X is
// issued only after a quorum prevoted X, and signatures are unforgeable.
// Liveness: rounds rotate the proposer deterministically; a stalled round
// times out into the next. The chain rule — a proposal is valid only if its
// head links to the last finalized ID — makes forks visible instead of
// silent.
//
// Deliberate simplifications for a permissioned v1 are listed in
// docs/BFT.md; none touch the safety argument above.
package bft
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"errors"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Phases of the protocol, encoded inside the signed message domain.
type Phase byte
const (
PhasePrevote Phase = 0x01
PhasePrecommit Phase = 0x02
)
// Errors reported by message validation.
var (
ErrBadSignature = errors.New("bft: signature does not verify")
ErrUnknownVoter = errors.New("bft: signer is not a validator")
ErrBadMessage = errors.New("bft: malformed message")
)
// Message is one signed vote: a prevote or precommit for a height/round and
// head. A zero HeadID encodes an explicit nil vote ("I saw no valid head").
type Message struct {
Phase Phase
Height uint64
Round uint64
HeadID [32]byte
pubkey ed25519.PublicKey
sig []byte
}
// SetProposal frames what the round's proposer offers: its latest head.
type Proposal struct {
Height uint64
Round uint64
HeadID [32]byte
}
func domain(p Phase) []byte {
switch p {
case PhasePrevote:
return []byte("trust.n1ko.dev/bft/prevote/1\x00")
case PhasePrecommit:
return []byte("trust.n1ko.dev/bft/precommit/1\x00")
}
return nil
}
func propDomain() []byte { return []byte("trust.n1ko.dev/bft/proposal/1\x00") }
// Encode returns the canonical bytes of a vote message.
func (m *Message) Encode() ([]byte, error) {
if m.Phase != PhasePrevote && m.Phase != PhasePrecommit {
return nil, ErrBadMessage
}
e := tce.NewEncoder()
e.Uvarint(m.Height)
e.Uvarint(m.Round)
e.FixedBytes("head_id", m.HeadID[:], 32)
body, err := e.Bytes()
if err != nil {
return nil, err
}
out := make([]byte, 0, len(domain(m.Phase))+len(body))
out = append(out, domain(m.Phase)...)
return append(out, body...), nil
}
// Sign produces the wire form: bytes, signature and the signer's public key.
func (m *Message) Sign(key ed25519.PrivateKey) (*Signed, error) {
b, err := m.Encode()
if err != nil {
return nil, err
}
if len(key) != ed25519.PrivateKeySize {
return nil, ErrBadSignature
}
return &Signed{
Bytes: b,
Signature: ed25519.Sign(key, b),
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
}, nil
}
// Signed is the wire form of any signed BFT object.
type Signed struct {
Bytes []byte `json:"bytes"`
Signature []byte `json:"signature"`
PubKeyHex string `json:"public_key"`
}
// VerifyVote checks a signed vote and decodes it.
func VerifyVote(s *Signed) (*Message, error) {
if s == nil || len(s.Bytes) < 34 {
return nil, ErrBadMessage
}
pub, err := hex.DecodeString(s.PubKeyHex)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnknownVoter
}
if len(s.Signature) != ed25519.SignatureSize ||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
return nil, ErrBadSignature
}
// Decode strictly against both domains: the phase byte lives in the
// prefix, so a prevote can never be replayed as a precommit.
var m Message
for _, phase := range []Phase{PhasePrevote, PhasePrecommit} {
d := domain(phase)
if string(s.Bytes[:len(d)]) == string(d) {
m.Phase = phase
body := s.Bytes[len(d):]
d2 := tce.NewDecoder(body)
h, err := d2.Uvarint()
if err != nil {
return nil, ErrBadMessage
}
m.Height = h
r, err := d2.Uvarint()
if err != nil {
return nil, ErrBadMessage
}
m.Round = r
rest, err := d2.FixedBytes(32)
if err != nil {
return nil, ErrBadMessage
}
copy(m.HeadID[:], rest)
if d2.End() != nil {
return nil, ErrBadMessage
}
m.pubkey = ed25519.PublicKey(pub)
m.sig = s.Signature
return &m, nil
}
}
return nil, ErrBadMessage
}
// Propose signs a proposal with the validator key.
func Propose(p *Proposal, key ed25519.PrivateKey) (*Signed, error) {
if len(key) != ed25519.PrivateKeySize {
return nil, ErrBadSignature
}
e := tce.NewEncoder()
e.Uvarint(p.Height)
e.Uvarint(p.Round)
e.FixedBytes("head_id", p.HeadID[:], 32)
body, err := e.Bytes()
if err != nil {
return nil, err
}
buf := make([]byte, 0, len(propDomain())+len(body))
buf = append(buf, propDomain()...)
buf = append(buf, body...)
return &Signed{
Bytes: buf,
Signature: ed25519.Sign(key, buf),
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
}, nil
}
// VerifyProposal checks a proposal was signed by the expected proposer for
// this height/round.
func VerifyProposal(s *Signed, set *Set, height, round uint64) (*Proposal, error) {
pub, err := hex.DecodeString(s.PubKeyHex)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnknownVoter
}
want := set.Proposer(height, round)
if string(want) != string(pub) {
return nil, ErrUnknownVoter // not this round's proposer
}
if len(s.Signature) != ed25519.SignatureSize ||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
return nil, ErrBadSignature
}
d := propDomain()
if len(s.Bytes) <= len(d)+32 || string(s.Bytes[:len(d)]) != string(d) {
return nil, ErrBadMessage
}
body := s.Bytes[len(d):]
d2 := tce.NewDecoder(body)
h, err := d2.Uvarint()
if err != nil || h != height {
return nil, ErrBadMessage
}
r, err := d2.Uvarint()
if err != nil || r != round {
return nil, ErrBadMessage
}
var p Proposal
p.Height, p.Round = h, r
head, err := d2.FixedBytes(32)
if err != nil || d2.End() != nil {
return nil, ErrBadMessage
}
copy(p.HeadID[:], head)
return &p, nil
}
// Set is the validator set: public keys plus reachable URLs, index-aligned.
type Set struct {
PubKeys []ed25519.PublicKey
URLs []string
}
// NewSet parses hex public keys paired with base URLs.
func NewSet(hexKeys []string, urls []string) (*Set, error) {
if len(hexKeys) != len(urls) {
return nil, errors.New("bft: validator keys and URLs must align")
}
s := &Set{}
for _, hk := range hexKeys {
raw, err := hex.DecodeString(hk)
if err != nil || len(raw) != ed25519.PublicKeySize {
return nil, errors.New("bft: bad validator pubkey")
}
s.PubKeys = append(s.PubKeys, ed25519.PublicKey(raw))
}
s.URLs = append(s.URLs, urls...)
return s, nil
}
// Quorum is the number of votes needed: 2f+1 where f tolerates Byzantine
// validators. A set of n tolerates f=(n-1)/3.
func (s *Set) Quorum() int {
n := len(s.PubKeys)
f := (n - 1) / 3
return 2*f + 1
}
// IsValidator reports whether pub belongs to the set.
func (s *Set) IsValidator(pub ed25519.PublicKey) bool {
for _, k := range s.PubKeys {
if string(k) == string(pub) {
return true
}
}
return false
}
// Proposer deterministically names the validator expected to propose for a
// height/round: SHA-256(height||round) selects the leader, so every node
// computes the same answer and equivocation by anyone else is ignored.
func (s *Set) Proposer(height, round uint64) ed25519.PublicKey {
sum := sha256.Sum256([]byte{byte(height >> 56), byte(height >> 48), byte(height >> 40), byte(height >> 32),
byte(height >> 24), byte(height >> 16), byte(height >> 8), byte(height),
byte(round >> 56), byte(round >> 48), byte(round >> 40), byte(round >> 32),
byte(round >> 24), byte(round >> 16), byte(round >> 8), byte(round)})
idx := int(sum[0]) % len(s.PubKeys)
return s.PubKeys[idx]
}
// Certificate assembles precommits for finality verification.
type Certificate struct {
Height uint64 `json:"height"`
HeadID string `json:"head_id"`
Precommits []Signed `json:"precommits"`
}
// Finalize attempts to build a certificate from collected precommits. It
// returns nil until quorum distinct validators have precommitted this exact
// head at this exact height/round.
func (s *Set) Finalize(height, round uint64, headID [32]byte, precommits []*Signed) *Certificate {
seen := make(map[string]struct{}, len(precommits))
var keep []Signed
for _, pc := range precommits {
m, err := VerifyVote(pc)
if err != nil || m.Phase != PhasePrecommit || m.Height != height ||
m.Round > round || m.HeadID != headID {
continue
}
k := pc.PubKeyHex
if _, dup := seen[k]; dup {
continue // one vote per validator
}
seen[k] = struct{}{}
keep = append(keep, *pc)
if len(seen) >= s.Quorum() {
return &Certificate{
Height: height,
HeadID: hex.EncodeToString(headID[:]),
Precommits: keep,
}
}
}
return nil
}
// VerifyCertificate checks a certificate offline: enough distinct validators,
// every precommit authentic, all agreeing on the claimed head and height.
func (s *Set) VerifyCertificate(c *Certificate) error {
var headID [32]byte
idb, err := hex.DecodeString(c.HeadID)
if err != nil || len(idb) != 32 {
return ErrBadMessage
}
copy(headID[:], idb)
seen := make(map[string]struct{}, len(c.Precommits))
for i := range c.Precommits {
pc := &c.Precommits[i]
m, err := VerifyVote(pc)
if err != nil || m.Phase != PhasePrecommit || m.Height != c.Height || m.HeadID != headID {
return ErrBadMessage
}
if !s.IsValidator(m.pubkey) {
return ErrUnknownVoter
}
if _, dup := seen[pc.PubKeyHex]; dup {
return ErrBadMessage
}
seen[pc.PubKeyHex] = struct{}{}
}
if len(seen) < s.Quorum() {
return errors.New("bft: certificate lacks quorum")
}
return nil
}

View file

@ -1,264 +0,0 @@
package bft_test
import (
"crypto/ed25519"
"encoding/hex"
"sync"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
)
// bus wires validators over an in-memory network. Delivery is synchronous;
// messages to offline members are dropped exactly like an unreachable peer.
type bus struct {
mu sync.Mutex
vals []*bft.Validator
online []bool
}
func (b *bus) deliver(from int) func(string, any) {
return func(path string, payload any) {
signed, ok := payload.(*bft.Signed)
if !ok {
return
}
// Snapshot targets under the lock, deliver outside it: handlers
// broadcast recursively, and the bus lock is not reentrant.
b.mu.Lock()
var targets []*bft.Validator
for i := range b.vals {
if i != from && b.online[i] {
targets = append(targets, b.vals[i])
}
}
b.mu.Unlock()
for _, v := range targets {
switch path {
case "proposal":
v.OnProposal(signed)
case "vote":
v.OnVote(signed)
}
}
}
}
// mesh builds n validators sharing one candidate head, some possibly offline.
func newMesh(t *testing.T, n int, online []bool, headID [32]byte) (*bus, []*bft.Validator) {
t.Helper()
keys := make([]ed25519.PrivateKey, n)
hexKeys := make([]string, n)
urls := make([]string, n)
for i := 0; i < n; i++ {
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
keys[i] = priv
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
urls[i] = string(rune('a' + i))
}
set, err := bft.NewSet(hexKeys, urls)
if err != nil {
t.Fatal(err)
}
b := &bus{online: online}
for i := 0; i < n; i++ {
i := i
v := bft.NewValidator(set, i, keys[i],
func() ([32]byte, bool) { return headID, true },
b.deliver(i))
b.vals = append(b.vals, v)
}
return b, b.vals
}
// startWhenProposerOnline drives rounds r=0..15 until the deterministic
// proposer belongs to the online set, then runs that round.
func startWhenProposerOnline(t *testing.T, vals []*bft.Validator, online []bool, h uint64) {
t.Helper()
for r := uint64(0); r < 16; r++ {
pi := proposerIndex(t, vals, h, r)
if pi < len(online) && online[pi] {
vals[pi].StartRound(h, r)
return
}
}
t.Fatal("no online proposer within 16 rounds")
}
func proposerIndex(t *testing.T, vals []*bft.Validator, h, r uint64) int {
t.Helper()
want := vals[0].Set.Proposer(h, r)
for i, v := range vals {
pub := hex.EncodeToString(v.Set.PubKeys[i])
if pub == hex.EncodeToString(want) {
return i
}
}
t.Fatal("proposer not found")
return -1
}
func TestFinalizesWithOneValidatorOffline(t *testing.T) {
common := testHeadByte(7)
online := []bool{true, true, true, false}
_, vals := newMesh(t, 4, online, common)
startWhenProposerOnline(t, vals, online, 1)
for i, v := range vals {
if !online[i] {
continue
}
if v.Height() != 2 {
t.Fatalf("validator %d stuck at height %d", i, v.Height())
}
cert := v.Certificate(1)
if cert == nil || cert.HeadID != hex.EncodeToString(common[:]) {
t.Fatalf("validator %d missing certificate %+v", i, cert)
}
// The certificate verifies offline against the full validator set.
fullSet, err := bft.NewSet(pubHexes(vals), urlsOf(4))
if err != nil {
t.Fatal(err)
}
if err := fullSet.VerifyCertificate(cert); err != nil {
t.Fatalf("certificate fails offline verification: %v", err)
}
}
// The offline validator never advanced.
if vals[3].Height() != 1 {
t.Fatal("offline validator unexpectedly advanced")
}
}
func TestSplitVoteNeverFinalizesTwoHeads(t *testing.T) {
headA := testHeadByte(1)
headB := testHeadByte(2)
keys, hexKeys := genKeys(4)
urls := urlsOf(4)
set, err := bft.NewSet(hexKeys, urls)
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
deliveries := map[int][]struct {
path string
p *bft.Signed
}{}
record := func(from int) func(string, any) {
return func(path string, payload any) {
mu.Lock()
defer mu.Unlock()
if s, ok := payload.(*bft.Signed); ok {
deliveries[from] = append(deliveries[from], struct {
path string
p *bft.Signed
}{path, s})
}
}
}
// Four validators, each seeing a DIFFERENT proposal: A,B see headA from
// the proposer; C,D see headB (equivocation by the proposer). No side
// reaches quorum, so nothing may finalize either head.
vals := make([]*bft.Validator, 4)
heads := [][32]byte{headA, headA, headB, headB}
for i := range vals {
i := i
vals[i] = bft.NewValidator(set, i, keys[i],
func() ([32]byte, bool) { return heads[i], true },
record(i))
}
pi := proposerIndex(t, vals, 1, 0)
pA, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headA}, keys[pi])
pB, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headB}, keys[pi])
vals[0].OnProposal(pA)
vals[1].OnProposal(pA)
vals[2].OnProposal(pB)
vals[3].OnProposal(pB)
for _, v := range vals {
if v.Height() != 1 {
t.Fatal("a height finalized despite an equivocating proposal")
}
if v.Certificate(1) != nil {
t.Fatal("certificate exists without quorum")
}
}
}
func TestCertificateTamperRejected(t *testing.T) {
common := testHeadByte(9)
online := []bool{true, true, true, false}
_, vals := newMesh(t, 4, online, common)
startWhenProposerOnline(t, vals, online, 1)
cert := vals[0].Certificate(1)
if cert == nil {
t.Fatal("no certificate produced")
}
fullSet, _ := bft.NewSet(pubHexes(vals), urlsOf(4))
// Flip one precommit signature byte.
bad := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
Precommits: append([]bft.Signed(nil), cert.Precommits...)}
bad.Precommits[0].Signature[0] ^= 0x01
if err := fullSet.VerifyCertificate(bad); err == nil {
t.Fatal("tampered certificate accepted")
}
// Drop votes below quorum.
short := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
Precommits: cert.Precommits[:1]}
if len(short.Precommits) >= fullSet.Quorum() {
t.Skip("quorum of 4-node set fits in one vote")
}
if err := fullSet.VerifyCertificate(short); err == nil {
t.Fatal("sub-quorum certificate accepted")
}
}
func genKeys(n int) ([]ed25519.PrivateKey, []string) {
keys := make([]ed25519.PrivateKey, n)
hexKeys := make([]string, n)
for i := 0; i < n; i++ {
_, priv, _ := ed25519.GenerateKey(nil)
keys[i] = priv
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
}
return keys, hexKeys
}
func urlsOf(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = string(rune('a' + i))
}
return out
}
func pubHexes(vals []*bft.Validator) []string {
out := make([]string, len(vals))
for i, v := range vals {
out[i] = hex.EncodeToString(v.Set.PubKeys[i])
}
return out
}
func testHeadByte(seed byte) [32]byte {
var h [32]byte
for i := range h {
h[i] = seed
}
return h
}

View file

@ -1,307 +0,0 @@
package bft
// The validator state machine. Transport is injected: a validator never
// performs I/O itself, so tests can wire four machines over an in-memory bus
// and the server can wire them over HTTP without the logic knowing the
// difference.
import (
"crypto/ed25519"
"encoding/hex"
"sync"
)
type roundKey struct{ h, r uint64 }
// Validator drives one member's participation.
type Validator struct {
Set *Set
Me int // index into Set.PubKeys
Key ed25519.PrivateKey
// LatestHead supplies this node's current chained head candidate (its
// ID must link to LastHead when a height starts).
LatestHead func() ([32]byte, bool)
// Broadcast fans a message out to every validator URL. It must deliver
// asynchronously or cheaply enough not to hold locks.
Broadcast func(path string, payload any)
mu sync.Mutex
height uint64
lastHead [32]byte
proposed map[roundKey]bool
prevoted map[roundKey][32]byte
precommitted map[roundKey][32]byte
acceptedProp map[roundKey]*Proposal
prevotes map[roundKey]map[string]*Signed
precommits map[roundKey][]*Signed
certs map[uint64]*Certificate
}
// NewValidator constructs one participant at index me of the set.
func NewValidator(set *Set, me int, key ed25519.PrivateKey,
latest func() ([32]byte, bool), broadcast func(string, any)) *Validator {
height := uint64(1)
return &Validator{
height: height,
Set: set,
Me: me,
Key: key,
LatestHead: latest,
Broadcast: broadcast,
proposed: make(map[roundKey]bool),
prevoted: make(map[roundKey][32]byte),
precommitted: make(map[roundKey][32]byte),
acceptedProp: make(map[roundKey]*Proposal),
prevotes: make(map[roundKey]map[string]*Signed),
precommits: make(map[roundKey][]*Signed),
certs: make(map[uint64]*Certificate),
}
}
// Height returns the next height awaiting finality.
func (v *Validator) Height() uint64 {
v.mu.Lock()
defer v.mu.Unlock()
return v.height
}
// LastFinalized returns the most recent finalized head ID.
func (v *Validator) LastFinalized() [32]byte {
v.mu.Lock()
defer v.mu.Unlock()
return v.lastHead
}
// PrevoteCount exposes how many prevotes a round collected; test aid.
func (v *Validator) PrevoteCount(h, r uint64) map[string]*Signed {
v.mu.Lock()
defer v.mu.Unlock()
return v.prevotes[roundKey{h, r}]
}
// AcceptedProposal exposes the accepted proposal of a round; test aid.
func (v *Validator) AcceptedProposal(h, r uint64) *Proposal {
v.mu.Lock()
defer v.mu.Unlock()
return v.acceptedProp[roundKey{h, r}]
}
// Certificate returns the stored certificate for a finalized height.
func (v *Validator) Certificate(h uint64) *Certificate {
v.mu.Lock()
defer v.mu.Unlock()
return v.certs[h]
}
// StartRound proposes when this validator leads the round. Called by the
// driver on timeouts and after each finality.
func (v *Validator) StartRound(h, r uint64) {
v.mu.Lock()
if h != v.height || v.proposed[roundKey{h, r}] {
v.mu.Unlock()
return
}
want := v.Set.Proposer(h, r)
if string(want) != string(v.Set.PubKeys[v.Me]) {
v.mu.Unlock()
return
}
head, ok := v.LatestHead()
if !ok {
// Nothing to offer this round; abstaining lets the round time out
// into the next proposer instead of committing garbage.
v.proposed[roundKey{h, r}] = true
v.mu.Unlock()
return
}
// After the first height a proposal must chain onto what we finalized;
// before that any head is acceptable (the anchor slot).
if v.lastHead != ([32]byte{}) && !chainsOnto(head, v.lastHead) {
v.proposed[roundKey{h, r}] = true
v.mu.Unlock()
return
}
v.proposed[roundKey{h, r}] = true
p := &Proposal{Height: h, Round: r, HeadID: head}
signed, err := Propose(p, v.Key)
v.mu.Unlock()
if err != nil {
return
}
// Self-delivery first: the transport skips the sender, so the proposer
// must process its own proposal exactly like any other validator would.
v.OnProposal(signed)
v.Broadcast("proposal", signed)
}
// nextExpected returns what head should be committed at height h: the last
// finalized head for the first height (the genesis slot re-affirms it) and,
// afterwards, whatever chains on. In practice relays commit NEW heads each
// height; the first height may carry the initial checkpoint.
func (v *Validator) nextExpected(h uint64) [32]byte {
_ = h
return v.lastHead
}
// OnProposal records the round's proposal. The first valid proposal from the
// expected proposer wins for the round; later ones are equivocation and are
// dropped. Returns true when the proposal was newly accepted.
func (v *Validator) OnProposal(s *Signed) bool {
v.mu.Lock()
h := v.height
r := uint64(0)
key := roundKey{h, r}
if v.acceptedProp[key] != nil || v.precommitted[key] != [32]byte{} || v.prevoted[key] != [32]byte{} {
// Round already advanced past accepting proposals.
v.mu.Unlock()
return false
}
p, err := VerifyProposal(s, v.Set, h, r)
if err != nil {
v.mu.Unlock()
return false
}
// Accept the round's proposal verbatim. Chain linkage is enforced where
// heads are produced (StartRound only proposes heads that chain onto
// lastHead); safety here comes from quorum agreement on one exact ID,
// which divergent chains cannot gather without a Byzantine third.
v.acceptedProp[key] = p
v.mu.Unlock()
// Prevote what we accepted (a real implementation would fetch-and-check
// the head body first; here the gossip layer already verified it).
v.castPrevote(h, r, p.HeadID)
return true
}
// castPrevote signs and broadcasts this validator's prevote once per round.
// chainsOnto reports whether a head builds on an anchor. Heads carry their
// previous checkpoint hash inside the signed checkpoint bytes; the gossip
// layer verified that linkage before exposing the head here. The ID check
// below is the cheap structural guard for tests and direct callers.
func chainsOnto(head, anchor [32]byte) bool {
return head != anchor
}
func (v *Validator) castPrevote(h, r uint64, head [32]byte) {
v.mu.Lock()
key := roundKey{h, r}
if _, done := v.prevoted[key]; done {
v.mu.Unlock()
return
}
v.prevoted[key] = head
m := &Message{Phase: PhasePrevote, Height: h, Round: r, HeadID: head}
signed, err := m.Sign(v.Key)
v.mu.Unlock()
if err != nil {
return
}
// Record our own vote before broadcasting so tallies include us.
v.OnVote(signed)
v.Broadcast("vote", signed)
}
// OnVote ingests any validator's signed vote, advancing the phases.
func (v *Validator) OnVote(s *Signed) {
m, err := VerifyVote(s)
if err != nil || !v.Set.IsValidator(m.pubkey) {
return
}
v.mu.Lock()
h := v.height
if m.Height > h {
v.mu.Unlock()
return // future height: ignore until we catch up
}
if m.Height < h {
// Past height: only useful for certificates we already have.
v.mu.Unlock()
return
}
if m.Phase == PhasePrevote {
key := roundKey{m.Height, m.Round}
bucket := v.prevotes[key]
if bucket == nil {
bucket = make(map[string]*Signed)
v.prevotes[key] = bucket
}
bucket[s.PubKeyHex] = s
count := len(bucket)
var agreed [32]byte
agreeing := 0
for _, other := range bucket {
vm, _ := VerifyVote(other)
if vm == nil {
continue
}
if agreeing == 0 {
agreed = vm.HeadID
agreeing = 1
} else if vm.HeadID == agreed {
agreeing++
}
}
myRound := key
alreadyPC := v.precommitted[myRound]
v.mu.Unlock()
// Quorum prevoted one head and we have not precommitted yet.
if count >= v.Set.Quorum() && agreeing >= v.Set.Quorum() &&
alreadyPC == [32]byte{} {
v.castPrecommit(m.Height, m.Round, agreed)
}
return
}
// PhasePrecommit.
key := roundKey{m.Height, m.Round}
v.precommits[key] = append(v.precommits[key], s)
precommits := append([]*Signed(nil), v.precommits[key]...)
height, round := m.Height, m.Round
v.mu.Unlock()
if cert := v.Set.Finalize(height, round, m.HeadID, precommits); cert != nil {
v.finalize(cert)
}
}
func (v *Validator) castPrecommit(h, r uint64, head [32]byte) {
v.mu.Lock()
key := roundKey{h, r}
if v.precommitted[key] != [32]byte{} {
v.mu.Unlock()
return
}
v.precommitted[key] = head
m := &Message{Phase: PhasePrecommit, Height: h, Round: r, HeadID: head}
signed, err := m.Sign(v.Key)
v.mu.Unlock()
if err != nil {
return
}
v.OnVote(signed)
v.Broadcast("vote", signed)
}
// finalize installs a certificate and moves to the next height. The head ID
// becomes the anchor the following height must chain onto.
func (v *Validator) finalize(cert *Certificate) {
v.mu.Lock()
defer v.mu.Unlock()
if cert.Height != v.height {
return // stale or future finality
}
if _, seen := v.certs[cert.Height]; seen {
return // already finalized this height; conflicting certs impossible
}
var id [32]byte
idb, _ := hex.DecodeString(cert.HeadID)
copy(id[:], idb)
v.certs[cert.Height] = cert
v.lastHead = id
v.height = cert.Height + 1
}

View file

@ -1,154 +0,0 @@
// Package checkpoint defines the relay's signed commitment to its object
// set: the header chain that light nodes follow.
//
// A checkpoint is transport-layer infrastructure. It commits to the Merkle
// root of the objects a relay stores, to the previous checkpoint (forming a
// tamper-evident chain per relay), and to nothing else. It is never a trust
// statement: the relay key that signs it may not sign claims or approvals,
// and consumers derive no authority from it beyond "this relay asserts its
// log looks like this". This is the documented narrowing of INV-1
// (docs/TRUST-MODEL.md): the relay holds one key whose entire power is to
// describe its own storage.
//
// Canonical encoding, reusing TCE primitives so that two implementations
// cannot disagree about byte-exactness:
//
// MAGIC("trust.n1ko.dev/ckpt/1\0") || version=1 ||
// epoch || size || root(32) || prev(32) || created_at
//
// All integers are uvarints; timestamps follow the protocol's range rules.
package checkpoint
import (
"crypto/ed25519"
"crypto/sha256"
"errors"
"fmt"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Magic frames a checkpoint and is domain separation at the outermost level:
// a checkpoint can never be reinterpreted as a TCE object or vice versa.
var Magic = []byte("trust.n1ko.dev/ckpt/1\x00")
// Version is the checkpoint format version.
const Version = 1
// Errors returned for malformed checkpoints.
var (
ErrBadMagic = errors.New("checkpoint: bad magic")
ErrVersion = errors.New("checkpoint: unsupported version")
ErrSignature = errors.New("checkpoint: signature does not verify")
)
// Checkpoint is one signed head of the relay's log.
type Checkpoint struct {
// Epoch counts checkpoints this relay has produced, starting at 1.
Epoch uint64
// Size is the number of distinct objects committed to by Root.
Size uint64
// Root is the sparse Merkle root over the stored object IDs.
Root [32]byte
// Prev is SHA-256 of the previous checkpoint's canonical bytes; zeros
// for epoch 1. Linking heads makes per-relay history tamper-evident
// without any global consensus.
Prev [32]byte
// CreatedAt is the relay's assertion of signing time.
CreatedAt uint64
}
// Encode returns the canonical bytes.
func (c *Checkpoint) Encode() ([]byte, error) {
if err := tce.ValidateTimestamp(c.CreatedAt, false); err != nil {
return nil, fmt.Errorf("checkpoint: created_at: %w", err)
}
e := tce.NewEncoder()
e.Uvarint(Version)
e.Uvarint(c.Epoch)
e.Uvarint(c.Size)
e.FixedBytes("root", c.Root[:], 32)
e.FixedBytes("prev", c.Prev[:], 32)
e.Uvarint(c.CreatedAt)
body, err := e.Bytes()
if err != nil {
return nil, err
}
out := make([]byte, 0, len(Magic)+len(body))
out = append(out, Magic...)
return append(out, body...), nil
}
// Decode strictly parses canonical bytes: exact magic, version, field sizes,
// timestamp range, no trailing bytes.
func Decode(b []byte) (*Checkpoint, error) {
if len(b) < len(Magic) || string(b[:len(Magic)]) != string(Magic) {
return nil, ErrBadMagic
}
d := tce.NewDecoder(b[len(Magic):])
version, err := d.Uvarint()
if err != nil {
return nil, err
}
if version != Version {
return nil, ErrVersion
}
var c Checkpoint
if c.Epoch, err = d.Uvarint(); err != nil {
return nil, err
}
if c.Size, err = d.Uvarint(); err != nil {
return nil, err
}
root, err := d.FixedBytes(32)
if err != nil {
return nil, err
}
copy(c.Root[:], root)
prev, err := d.FixedBytes(32)
if err != nil {
return nil, err
}
copy(c.Prev[:], prev)
if c.CreatedAt, err = d.Uvarint(); err != nil {
return nil, err
}
if err := tce.ValidateTimestamp(c.CreatedAt, false); err != nil {
return nil, fmt.Errorf("checkpoint: created_at: %w", err)
}
if err := d.End(); err != nil {
return nil, err
}
return &c, nil
}
// ID is the content address of a checkpoint: SHA-256 of its canonical
// bytes. It is also the value linked by the next checkpoint's Prev.
func ID(b []byte) [32]byte {
return sha256.Sum256(b)
}
// Sign returns the Ed25519 signature of the relay key over the canonical
// bytes. Only a relay's transport key ever produces this.
func Sign(key ed25519.PrivateKey, b []byte) ([]byte, error) {
if len(key) != ed25519.PrivateKeySize {
return nil, errors.New("checkpoint: bad relay key")
}
return ed25519.Sign(key, b), nil
}
// Verify checks a checkpoint's signature over its canonical bytes.
func Verify(key ed25519.PublicKey, b, sig []byte) error {
if len(key) != ed25519.PublicKeySize || len(sig) != ed25519.SignatureSize {
return ErrSignature
}
// ed25519.Verify panics on a mis-sized key, hence the length gate above.
if !ed25519.Verify(key, b, sig) {
return ErrSignature
}
return nil
}

View file

@ -1,126 +0,0 @@
package checkpoint_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
)
func testCkpt() *checkpoint.Checkpoint {
var root, prev [32]byte
rand.Read(root[:])
rand.Read(prev[:])
return &checkpoint.Checkpoint{
Epoch: 3,
Size: 42,
Root: root,
Prev: prev,
CreatedAt: 1_700_000_000,
}
}
func TestEncodeDecodeRoundTrip(t *testing.T) {
c := testCkpt()
b, err := c.Encode()
if err != nil {
t.Fatal(err)
}
got, err := checkpoint.Decode(b)
if err != nil {
t.Fatal(err)
}
if got.Epoch != c.Epoch || got.Size != c.Size || got.CreatedAt != c.CreatedAt {
t.Fatal("field mismatch")
}
if got.Root != c.Root || got.Prev != c.Prev {
t.Fatal("hash mismatch")
}
again, _ := c.Encode()
if !bytes.Equal(b, again) {
t.Fatal("encoding nondeterministic")
}
}
func TestGenesisPrevIsZeros(t *testing.T) {
c := &checkpoint.Checkpoint{Epoch: 1, Size: 0, CreatedAt: 1_700_000_000}
if c.Prev != ([32]byte{}) {
t.Fatal("genesis prev must be zeros")
}
}
func TestTamperedBytesRejected(t *testing.T) {
c := testCkpt()
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
b, err := c.Encode()
if err != nil {
t.Fatal(err)
}
sig, err := checkpoint.Sign(priv, b)
if err != nil {
t.Fatal(err)
}
if err := checkpoint.Verify(pub, b, sig); err != nil {
t.Fatal("honest signature rejected")
}
for i := len(checkpoint.Magic); i < len(b); i++ {
bad := append([]byte(nil), b...)
bad[i] ^= 0x01
if checkpoint.Verify(pub, bad, sig) == nil {
t.Fatalf("tampered byte %d verified", i)
}
// A mutated body is also a different checkpoint entirely.
if _, err := checkpoint.Decode(bad); err == nil && i >= len(b)-1 {
// Mutating created_at's last byte may still decode if it stays
// in range; signature binding covers that case above.
continue
}
}
}
func TestDecodeRejectsGarbage(t *testing.T) {
cases := [][]byte{
nil,
{},
bytes.Repeat([]byte{0x00}, 21),
append(append([]byte(nil), checkpoint.Magic...), 0x02), // unknown version
checkpoint.Magic[:10],
}
for i, b := range cases {
if _, err := checkpoint.Decode(b); err == nil {
t.Errorf("case %d decoded garbage", i)
}
}
// Trailing byte after the last field.
c := testCkpt()
b, _ := c.Encode()
if _, err := checkpoint.Decode(append(b, 0x00)); err == nil {
t.Error("trailing byte accepted")
}
}
func TestChainLinkage(t *testing.T) {
c1 := &checkpoint.Checkpoint{Epoch: 1, Size: 5, CreatedAt: 1_700_000_000}
b1, _ := c1.Encode()
id1 := checkpoint.ID(b1)
c2 := &checkpoint.Checkpoint{Epoch: 2, Size: 6, Root: id1, Prev: id1, CreatedAt: 1_700_000_060}
if c2.Prev != id1 {
t.Fatal("prev must equal previous checkpoint's ID")
}
}
func TestSignKeySizeEnforced(t *testing.T) {
b, _ := testCkpt().Encode()
if _, err := checkpoint.Sign(ed25519.PrivateKey(make([]byte, 10)), b); err == nil {
t.Fatal("short private key accepted")
}
if err := checkpoint.Verify(ed25519.PublicKey(make([]byte, 10)), b, make([]byte, 64)); err == nil {
t.Fatal("short public key accepted")
}
if err := checkpoint.Verify(ed25519.PublicKey(make([]byte, 32)), b, make([]byte, 63)); err == nil {
t.Fatal("short signature accepted")
}
}

View file

@ -138,19 +138,9 @@ func TestProtocolDoesNotImportSigner(t *testing.T) {
}
// TestNoSigningOutsideSigner enforces INV-1 at the source level: the ability
// to produce an Ed25519 signature must exist in exactly one place for trust
// statements, plus one narrowly-scoped exception.
//
// Exception (INV-1 amendment, docs/TRUST-MODEL.md): the relay holds a
// transport key used solely to sign its own log-integrity checkpoints
// (internal/checkpoint) and, when acting as a BFT finality validator, to
// sign votes about those checkpoints (internal/bft). These keys can
// describe storage; they can never mint a
// claim, approval or any other statement, because statement signing lives in
// internal/identity/signer and the server packages are structurally barred
// from importing it (TestServerDoesNotImportSigner). If either checkpoint
// file grows a reference to protocol objects or the signer package, the
// import-graph tests fail immediately.
// to produce an Ed25519 signature must exist in exactly one package: the
// client-only signer. A relay that starts signing anything fails here the
// moment the code is written.
func TestNoSigningOutsideSigner(t *testing.T) {
requireToolchain(t)
files := goList(t, "-f",
@ -163,29 +153,10 @@ func TestNoSigningOutsideSigner(t *testing.T) {
"ed25519.NewKeyFromSeed(",
}
transportKeyHomes := []string{
"/internal/checkpoint/",
"/internal/server/checkpoint.go",
"/internal/bft/",
}
for _, file := range files {
// The signer package is the one permitted home for statement keys;
// the checkpoint files are the one permitted home for the relay's
// transport key.
if strings.Contains(file, "/internal/identity/signer/") {
continue
}
transport := false
for _, home := range transportKeyHomes {
if strings.Contains(file, home) {
transport = true
break
}
}
if transport {
continue
}
data, err := readFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
@ -193,8 +164,7 @@ func TestNoSigningOutsideSigner(t *testing.T) {
for _, api := range signingAPIs {
if strings.Contains(data, api) {
t.Errorf("%s uses %s outside the signer package; "+
"private key operations must stay in internal/identity/signer "+
"(or the relay's checkpoint transport key)", file, api)
"private key operations must stay in internal/identity/signer", file, api)
}
}
}

View file

@ -1,212 +0,0 @@
package lightnode
// Upstream authentication. Reading streams at a relay requires a session,
// and sessions require an AuthAssertion signed by *some* key. The light node
// therefore holds one ephemeral Ed25519 key used purely as a transport
// credential: it signs nothing but its own login, grants nothing, and is
// regenerated on every start unless a seed directory is configured.
//
// The handshake must survive relays that run proof-of-work admission
// control: when the challenge endpoint answers 429 "proof of work required",
// the node solves the keyed BLAKE3 puzzle for the zero target exactly like
// any other client.
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
)
type upstreamAuth struct {
client *http.Client
// key is the node's ephemeral transport credential. All private-key
// handling stays inside internal/identity/signer, keeping the
// source-scanned invariant intact: this key signs exactly one thing,
// the node's own login assertion.
key *signer.Signer
pubHex string
// per-peer session state, guarded by the node mutex in practice; each
// peer gets its own token.
tokens map[string]string
}
func newUpstreamAuth(seedDir string, client *http.Client) (*upstreamAuth, error) {
if seedDir != "" {
path := seedDir + "/transport_key.seed"
// 32 bytes = ed25519 seed size; signer.FromSeed validates the rest.
if seed, err := os.ReadFile(path); err == nil && len(seed) == 32 {
signerKey, err := signer.FromSeed(seed)
if err != nil {
return nil, err
}
return &upstreamAuth{
client: client,
key: signerKey,
pubHex: hex.EncodeToString(signerKey.Public()),
tokens: make(map[string]string),
}, nil
}
}
gen, err := signer.Generate()
if err != nil {
return nil, err
}
if seedDir != "" {
if err := os.WriteFile(seedDir+"/transport_key.seed", gen.Seed(), 0o600); err != nil {
return nil, err
}
}
return &upstreamAuth{
client: client,
key: gen,
pubHex: hex.EncodeToString(gen.Public()),
tokens: make(map[string]string),
}, nil
}
var (
errAuthFailed = errors.New("lightnode: upstream authentication failed")
)
// session returns a live bearer token for the peer, performing (and caching)
// the challenge/assert dance when needed. A 401 downstream invalidates the
// cached token once.
func (a *upstreamAuth) session(ctx context.Context, peer string) (string, error) {
if tok := a.tokens[peer]; tok != "" {
return tok, nil
}
tok, err := a.authenticate(ctx, peer)
if err != nil {
return "", err
}
a.tokens[peer] = tok
return tok, nil
}
func (a *upstreamAuth) invalidate(peer string) { delete(a.tokens, peer) }
func (a *upstreamAuth) authenticate(ctx context.Context, peer string) (string, error) {
body, status, err := postJSON(ctx, a.client, peer+"/v1/auth/challenge", nil)
if err == nil && status == http.StatusTooManyRequests && bytes.Contains(body, []byte("proof of work")) {
// Admission control is on: fetch a PoW challenge, solve for the
// zero target, retry with the proof attached.
powBody, pstatus, perr := postJSON(ctx, a.client, peer+"/v1/pow/challenge", map[string]string{"purpose": "auth"})
if perr != nil || pstatus != http.StatusOK {
return "", fmt.Errorf("%w: pow challenge", errAuthFailed)
}
var pc struct {
Key string `json:"key"`
Difficulty int `json:"difficulty"`
}
json.Unmarshal(powBody, &pc)
key, err := pow.ParseKey(pc.Key)
if err != nil {
return "", errAuthFailed
}
counter, ok := pow.Solve(key, [pow.TargetSize]byte{}, pc.Difficulty)
if !ok {
return "", errAuthFailed
}
body, status, err = postJSON(ctx, a.client, peer+"/v1/auth/challenge",
map[string]any{"pow": map[string]any{"key": pc.Key, "counter": counter}})
}
if err != nil || status != http.StatusOK {
return "", fmt.Errorf("%w: challenge status %d", errAuthFailed, status)
}
var ch struct {
Challenge string `json:"challenge"`
}
json.Unmarshal(body, &ch)
chBytes, err := hex.DecodeString(ch.Challenge)
if err != nil || len(chBytes) != 32 {
return "", errAuthFailed
}
audience, err := a.audience(ctx, peer)
if err != nil {
return "", err
}
a2 := &protocol.AuthAssertion{
PubKey: a.key.Public(),
Challenge: chBytes,
Scope: "read",
Audience: audience,
CreatedAt: uint64(time.Now().Unix()),
}
tb, err := protocol.EncodeAuthAssertion(a2)
if err != nil {
return "", errAuthFailed
}
sig := a.key.Sign(tb)
envBody, _, err := postJSON(ctx, a.client, peer+"/v1/auth/assert",
map[string]any{"tce": base64.StdEncoding.EncodeToString(tb),
"signature": base64.StdEncoding.EncodeToString(sig)})
if err != nil || !bytes.Contains(envBody, []byte("session_token")) {
return "", errAuthFailed
}
var out struct {
SessionToken string `json:"session_token"`
}
json.Unmarshal(envBody, &out)
if out.SessionToken == "" {
return "", errAuthFailed
}
return out.SessionToken, nil
}
func postJSON(ctx context.Context, client *http.Client, url string, body any) ([]byte, int, error) {
var rd io.Reader
if body != nil {
raw, _ := json.Marshal(body)
rd = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rd)
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return raw, resp.StatusCode, err
}
var _ = base64.StdEncoding
// audience fetches and caches the relay's audience binding.
func (a *upstreamAuth) audience(ctx context.Context, peer string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/config", nil)
if err != nil {
return "", err
}
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var cfg struct {
Audience string `json:"audience"`
}
json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&cfg)
if cfg.Audience == "" {
return "", errAuthFailed
}
return cfg.Audience, nil
}

View file

@ -1,463 +0,0 @@
package lightnode
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"github.com/coder/websocket"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
)
// errNoDecision is returned until the first quorum-approved head exists.
var errNoDecision = errors.New("lightnode: no agreed checkpoint yet")
func (n *Node) pickPeer() string {
n.mu.Lock()
defer n.mu.Unlock()
for _, p := range n.cfg.Peers {
return p
}
return ""
}
// objectFromCache returns a cached envelope body, if present.
func (n *Node) objectFromCache(id string) ([]byte, bool) {
if n.cfg.CacheDir == "" {
return nil, false
}
raw, err := os.ReadFile(filepath.Join(n.cfg.CacheDir, "objects", id+".json"))
return raw, err == nil
}
func (n *Node) cacheObject(id string, body []byte) {
if n.cfg.CacheDir == "" {
return
}
dir := filepath.Join(n.cfg.CacheDir, "objects")
if os.MkdirAll(dir, 0o700) == nil {
_ = os.WriteFile(filepath.Join(dir, id+".json"), body, 0o600)
}
n.evictLocked()
}
// evictLocked drops oldest cached objects while over budget.
func (n *Node) evictLocked() {
dir := filepath.Join(n.cfg.CacheDir, "objects")
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type item struct {
name string
size int64
mod int64
}
var items []item
var total int64
for _, e := range entries {
fi, err := e.Info()
if err != nil {
continue
}
items = append(items, item{e.Name(), fi.Size(), fi.ModTime().UnixNano()})
total += fi.Size()
}
if total <= n.cfg.CacheMaxBytes {
return
}
sort.Slice(items, func(i, j int) bool { return items[i].mod < items[j].mod })
for _, it := range items {
if total <= n.cfg.CacheMaxBytes {
break
}
if os.Remove(filepath.Join(dir, it.name)) == nil {
total -= it.size
}
}
}
// decisionRoot returns the root every served proof must match.
func (n *Node) decisionRoot() ([32]byte, error) {
n.mu.Lock()
defer n.mu.Unlock()
if n.decision == nil {
return [32]byte{}, errNoDecision
}
return n.decision.Root, nil
}
// decisionRootOK is decisionRoot with a boolean for callers that treat "no
// decision yet" as a plain no-op.
func (n *Node) decisionRootOK() ([32]byte, bool) {
r, err := n.decisionRoot()
return r, err == nil
}
// inclusionProofFromPeer fetches an inclusion proof from any peer. The
// caller verifies it against the root it trusts.
func (n *Node) inclusionProofFromPeer(idHex string) ([]byte, error) {
pr, err := n.verifiedProof(context.Background(), idHex, true)
if err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(pr.Proof)
}
type proofResponse struct {
Proof string `json:"proof"`
Root string `json:"root"`
}
// verifiedProof fetches an inclusion/absence proof from any peer and accepts
// it only if it verifies against the quorum-agreed root.
func (n *Node) verifiedProof(ctx context.Context, idHex string, wantPresent bool) (*proofResponse, error) {
root, err := n.decisionRoot()
if err != nil {
return nil, err
}
kind := "absent"
if wantPresent {
kind = "object"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
n.pickPeer()+"/v1/proof/"+kind+"/"+idHex, nil)
if err != nil {
return nil, err
}
resp, err := n.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("lightnode: proof status " + resp.Status)
}
var pr proofResponse
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&pr) != nil {
return nil, errors.New("lightnode: bad proof json")
}
proof, err := base64.StdEncoding.DecodeString(pr.Proof)
if err != nil {
return nil, err
}
var id [32]byte
idb, err := hex.DecodeString(idHex)
if err != nil || len(idb) != 32 {
return nil, errors.New("lightnode: bad id")
}
copy(id[:], idb)
if wantPresent && !smt.VerifyInclusion(root, id, proof) {
return nil, errors.New("lightnode: inclusion proof rejected against quorum root")
}
if !wantPresent && !smt.VerifyAbsence(root, id, proof) {
return nil, errors.New("lightnode: absence proof rejected against quorum root")
}
return &pr, nil
}
// Handler returns the relay-compatible read API plus node status.
func (n *Node) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/objects/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if len(id) != 64 {
writeErr(w, http.StatusBadRequest, "bad object id")
return
}
if body, ok := n.objectFromCache(id); ok {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "hit")
w.Write(body)
return
}
pr, err := n.verifiedProof(r.Context(), id, true)
if err != nil {
writeErr(w, http.StatusConflict, err.Error())
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
n.pickPeer()+"/v1/objects/"+id, nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
resp, err := n.http.Do(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
writeErr(w, resp.StatusCode, string(body))
return
}
n.cacheObject(id, body)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Proof-Root", pr.Root)
w.Write(body)
})
mux.HandleFunc("GET /v1/checkpoint/latest", func(w http.ResponseWriter, r *http.Request) {
n.mu.Lock()
d := n.decision
n.mu.Unlock()
if d == nil {
writeErr(w, http.StatusNotFound, "no agreed checkpoint yet")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"bytes": base64.StdEncoding.EncodeToString(d.Bytes),
"signature": base64.StdEncoding.EncodeToString(d.Signature),
"id": d.ID,
"public_key": d.PublicKey,
})
})
mux.HandleFunc("GET /v1/proof/object/{id}", func(w http.ResponseWriter, r *http.Request) {
serveVerifiedProof(n, w, r, true)
})
mux.HandleFunc("GET /v1/proof/absent/{id}", func(w http.ResponseWriter, r *http.Request) {
serveVerifiedProof(n, w, r, false)
})
mux.HandleFunc("GET /v1/config", func(w http.ResponseWriter, r *http.Request) {
keys := make([]string, 0, len(n.pins))
for k := range n.pins {
keys = append(keys, k)
}
sort.Strings(keys)
writeJSON(w, http.StatusOK, map[string]any{
"mode": "lightnode",
"peers": n.cfg.Peers,
"pinned_keys": keys,
"quorum": n.cfg.Quorum,
})
})
mux.HandleFunc("GET /v1/bft/certificate/{height}", func(w http.ResponseWriter, r *http.Request) {
if len(n.cfg.BFTValidators) == 0 {
writeErr(w, http.StatusNotImplemented, "no validator set pinned")
return
}
h := r.PathValue("height")
for _, peer := range n.cfg.Peers {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
strings.TrimSuffix(peer, "/")+"/v1/bft/certificate/"+h, nil)
if err != nil {
continue
}
resp, err := n.http.Do(req)
if err != nil {
continue
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
continue
}
var cert bft.Certificate
if json.Unmarshal(raw, &cert) != nil {
continue
}
set, err := bft.NewSet(n.cfg.BFTValidators, make([]string, len(n.cfg.BFTValidators)))
if err != nil || set.VerifyCertificate(&cert) != nil {
writeErr(w, http.StatusConflict, "certificate fails against pinned validators")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(raw)
return
}
writeErr(w, http.StatusBadGateway, "no peer served a certificate")
})
mux.HandleFunc("GET /v1/ws", func(w http.ResponseWriter, r *http.Request) {
serveDownstreamWS(n, w, r)
})
mux.HandleFunc("GET /v1/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /v1/readyz", func(w http.ResponseWriter, r *http.Request) {
n.mu.Lock()
d := n.decision
n.mu.Unlock()
if d == nil {
writeErr(w, http.StatusServiceUnavailable, "no agreed checkpoint yet")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
return mux
}
func serveVerifiedProof(n *Node, w http.ResponseWriter, r *http.Request, wantPresent bool) {
id := r.PathValue("id")
if len(id) != 64 {
writeErr(w, http.StatusBadRequest, "bad object id")
return
}
pr, err := n.verifiedProof(r.Context(), id, wantPresent)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, errNoDecision) {
status = http.StatusServiceUnavailable
} else if strings.Contains(err.Error(), "rejected") {
status = http.StatusConflict
}
writeErr(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, pr)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// Run refreshes on a ticker until ctx ends.
func (n *Node) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = n.Refresh(ctx)
}
}
}
// serveDownstreamWS upgrades a local client and mirrors the relay's frame
// protocol. No authentication: the light node is an operator-run local
// mirror, and its own credentials never delegate.
func serveDownstreamWS(n *Node, w http.ResponseWriter, r *http.Request) {
wsn, err := n.WS()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ws unavailable")
return
}
conn, err := websocketAccept(w, r)
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "")
client := &lnClient{send: make(chan []byte, lnSendBuffer), done: make(chan struct{})}
wsn.hub.mu.Lock()
if len(wsn.hub.clients) >= lnMaxConns {
wsn.hub.mu.Unlock()
conn.Close(websocket.StatusPolicyViolation, "too many connections")
return
}
wsn.hub.clients[client] = struct{}{}
wsn.hub.mu.Unlock()
defer wsn.hub.evict(client)
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-r.Context().Done():
return
case <-client.done:
conn.Close(websocket.StatusPolicyViolation, "evicted")
return
case payload := <-client.send:
if err := conn.Write(r.Context(), websocket.MessageText, payload); err != nil {
return
}
}
}
}()
for {
typ, raw, err := conn.Read(r.Context())
if err != nil {
break
}
if typ != websocket.MessageText {
continue
}
var msg struct {
Op string `json:"op"`
Channel string `json:"channel"`
Key string `json:"key"`
}
if json.Unmarshal(raw, &msg) != nil || (msg.Op != "subscribe" && msg.Op != "unsubscribe") {
safeSend(conn, r, map[string]string{"event": "error", "message": "bad control frame"})
continue
}
if !lnChannelKnown(msg.Channel) {
safeSend(conn, r, map[string]string{"event": "error", "message": "unknown channel"})
continue
}
wsn.hub.mu.Lock()
switch msg.Op {
case "subscribe":
if msg.Key == "" || len(client.subs) >= lnMaxSubsPerConn {
wsn.hub.mu.Unlock()
safeSend(conn, r, map[string]string{"event": "error", "message": "bad key or too many subscriptions"})
continue
}
client.subs = append(client.subs, lnSub{msg.Channel, msg.Key})
wsn.hub.demand[lnSub{msg.Channel, msg.Key}]++
case "unsubscribe":
kept := client.subs[:0]
for _, x := range client.subs {
if x.channel == msg.Channel && (msg.Key == "" || x.key == msg.Key) {
k := lnSub{x.channel, x.key}
wsn.hub.demand[k]--
if wsn.hub.demand[k] <= 0 {
delete(wsn.hub.demand, k)
}
continue
}
kept = append(kept, x)
}
client.subs = kept
}
wsn.hub.mu.Unlock()
wsn.hub.signal()
safeSend(conn, r, map[string]string{"event": "subscribed", "channel": msg.Channel, "key": msg.Key})
}
<-done // let the writer drain before the deferred close kills it
}
func safeSend(conn *websocket.Conn, r *http.Request, v any) {
raw, _ := json.Marshal(v)
_ = conn.Write(r.Context(), websocket.MessageText, raw)
}
func lnChannelKnown(ch string) bool {
switch ch {
case "claims", "requests", "responses", "revocations":
return true
}
return false
}

View file

@ -1,366 +0,0 @@
package lightnode_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// relayFixture is one full relay plus a helper to store objects into it.
type relayFixture struct {
ts *httptest.Server
srv *server.Server
}
func newRelay(t *testing.T, dir string) *relayFixture {
t.Helper()
srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ctx, cancel := context.WithCancel(context.Background())
srv.StartCheckpoints(ctx)
t.Cleanup(cancel)
return &relayFixture{ts: httptest.NewServer(srv.Handler()), srv: srv}
}
func (f *relayFixture) putEnvelope(t *testing.T, b, sig []byte) string {
t.Helper()
resp, err := http.Post(f.ts.URL+"/v1/objects", "application/json",
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(b), "signature": b64(sig)})))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("put status %d: %s", resp.StatusCode, raw)
}
var out struct {
ObjectID string `json:"object_id"`
}
json.NewDecoder(resp.Body).Decode(&out)
return out.ObjectID
}
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func TestLightnodeQuorumAndServing(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
c := newRelay(t, filepath.Join(dir, "c"))
// Both relays must hold the SAME object set: the root is a function of
// the set, so agreement is possible only over identical storage.
b, sig := makeTestClaim(t)
objA := a.putEnvelope(t, b, sig)
objC := c.putEnvelope(t, b, sig)
if objA != objC {
t.Fatal("same content produced different ids")
}
cfgA := getConfigPubkeys(t, a.ts.URL)
cfgC := getConfigPubkeys(t, c.ts.URL)
cacheDir := filepath.Join(dir, "cache")
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL, c.ts.URL},
PinKeys: []string{cfgA, cfgC},
Quorum: 2,
CacheDir: cacheDir,
CacheMaxBytes: 1 << 20,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go node.Run(ctx, 50*time.Millisecond)
// Wait for the first agreed decision.
deadline := time.Now().Add(2 * time.Second)
for node.Decision() == nil {
if time.Now().After(deadline) {
t.Fatal("no quorum decision reached")
}
time.Sleep(10 * time.Millisecond)
}
// Serve an object through the light node and verify caching works even
// after both peers disappear.
lts := httptest.NewServer(node.Handler())
defer lts.Close()
fetch := func(url string) int {
resp, err := http.Get(url + "/v1/objects/" + objA)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
if code := fetch(lts.URL); code != http.StatusOK {
t.Fatalf("first fetch status %d", code)
}
// Absence of an unknown object must also verify against the root
// (needs a live peer for proof transport).
resp, err := http.Get(lts.URL + "/v1/proof/absent/" + hexOnes())
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("verified absence status %d", resp.StatusCode)
}
a.ts.Close()
c.ts.Close()
// The fetched object survives peer loss from the local cache.
if code := fetch(lts.URL); code != http.StatusOK {
t.Fatalf("cached fetch after peers down: status %d", code)
}
if _, err := os.Stat(filepath.Join(cacheDir, "objects", objA+".json")); err != nil {
t.Fatal("object not cached to disk:", err)
}
}
func TestLightnodeRejectsSplitView(t *testing.T) {
dir := t.TempDir()
// Two relays with disjoint object sets commit to different roots.
x := newRelay(t, filepath.Join(dir, "x"))
y := newRelay(t, filepath.Join(dir, "y"))
bx, sx := makeTestClaim(t)
x.putEnvelope(t, bx, sx)
by, sy := makeTestClaim(t)
y.putEnvelope(t, by, sy)
node := lightnode.New(lightnode.Config{
Peers: []string{x.ts.URL, y.ts.URL},
PinKeys: []string{getConfigPubkeys(t, x.ts.URL), getConfigPubkeys(t, y.ts.URL)},
Quorum: 2,
})
if err := node.Refresh(context.Background()); err == nil {
t.Skip("sets happened to agree; rerun")
} else if node.Decision() != nil {
t.Fatal("decision made despite split view")
}
}
func TestLightnodeUnpinnedKeyRejectedWhenPinningEnabled(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
other := newRelay(t, filepath.Join(dir, "other"))
b1, s1 := makeTestClaim(t)
a.putEnvelope(t, b1, s1)
b2, s2 := makeTestClaim(t)
other.putEnvelope(t, b2, s2)
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL, other.ts.URL},
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)}, // only A is trusted
})
err := node.Refresh(context.Background())
if err == nil && node.Decision() == nil {
t.Fatal("expected error or decision")
}
if err == nil {
// Refresh succeeded only because quorum defaults to len(peers)=2...
// With pinning on, the unpinned peer must not have contributed.
t.Log("refresh ok; verifying contribution count via pins")
if len(node.Decision().PublicKey) == 0 {
t.Fatal("empty decision")
}
} else if err != lightnode.ErrUnpinnedKey && err.Error() != lightnode.ErrNoQuorum.Error() {
t.Fatalf("unexpected error: %v", err)
}
}
func getConfigPubkeys(t *testing.T, url string) string {
t.Helper()
resp, err := http.Get(url + "/v1/config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var cfg struct {
RelayPubkey string `json:"relay_pubkey"`
}
json.NewDecoder(resp.Body).Decode(&cfg)
if cfg.RelayPubkey == "" {
t.Fatal("peer did not expose relay_pubkey")
}
return cfg.RelayPubkey
}
func hexOnes() string {
b := make([]byte, 32)
for i := range b {
b[i] = 0xEE
}
return hex.EncodeToString(b)
}
func makeTestClaim(t *testing.T) ([]byte, []byte) {
t.Helper()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"light.test": tce.Bool(true)},
CreatedAt: uint64(time.Now().Unix()),
Serial: 1,
Nonce: bytes.Repeat([]byte{0x07}, tce.NonceSize),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return b, issuer.Sign(b)
}
func TestWSMirroredThroughLightnode(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
subjAddr := subject.Identity().String()
mkClaim := func(nonce byte, serial uint64) ([]byte, []byte) {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"ws.mirror": tce.Bool(true)},
CreatedAt: uint64(time.Now().Unix()),
Serial: serial,
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
}
cb, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return cb, issuer.Sign(cb)
}
b, sig := mkClaim(0x60, 6)
a.putEnvelope(t, b, sig)
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL},
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go node.Run(ctx, 30*time.Millisecond)
deadline := time.Now().Add(2 * time.Second)
for node.Decision() == nil {
if time.Now().After(deadline) {
t.Fatal("no quorum decision")
}
time.Sleep(5 * time.Millisecond)
}
wsn, err := node.WS()
if err != nil {
t.Fatal(err)
}
go wsn.RunWS(ctx)
lts := httptest.NewServer(node.Handler())
defer lts.Close()
wsURL := "ws" + strings.TrimPrefix(lts.URL, "http") + "/v1/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
readEvent := func() map[string]any {
t.Helper()
_, raw, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var ev map[string]any
json.Unmarshal(raw, &ev)
return ev
}
subRaw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": "claims", "key": subjAddr})
if err := conn.Write(ctx, websocket.MessageText, subRaw); err != nil {
t.Fatal(err)
}
if ev := readEvent(); ev["event"] != "subscribed" {
t.Fatalf("no ack: %v", ev)
}
// The relay only broadcasts at store time, so wait until the mirror's
// upstream subscription is live before publishing the matched claim.
deadline2 := time.Now().Add(3 * time.Second)
for !wsn.UpstreamReady() {
if time.Now().After(deadline2) {
t.Fatal("upstream stream never came up")
}
time.Sleep(10 * time.Millisecond)
}
time.Sleep(150 * time.Millisecond) // let the relay process the subscribe frame
// A second claim about the subscribed subject flows through the mirror.
cb, csig := mkClaim(0x61, 7)
resp, err := http.Post(a.ts.URL+"/v1/objects", "application/json",
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(cb), "signature": b64(csig)})))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
ev := readEvent()
if ev["event"] != "object" || ev["key"] != subjAddr {
t.Fatalf("unexpected event: %v", ev)
}
envMap, _ := ev["envelope"].(map[string]any)
if envMap == nil || envMap["tce"] == nil || envMap["signature"] == nil {
t.Fatal("event missing raw envelope")
}
if v, ok := ev["verified"].(bool); !ok || v {
t.Fatalf("fresh stream must be marked unverified: %v", ev["verified"])
}
// The streamed envelope must verify locally exactly like a fetched one.
tb, _ := base64.StdEncoding.DecodeString(envMap["tce"].(string))
tsig, _ := base64.StdEncoding.DecodeString(envMap["signature"].(string))
typName, verr := (&transport.Envelope{TCE: tb, Signature: tsig}).Verify()
if verr != nil || typName != "claim" {
t.Fatalf("streamed envelope fails local verification: %v %q", verr, typName)
}
}

View file

@ -1,262 +0,0 @@
// Package lightnode implements a verifying mirror of the relay API that
// stores no history: it tracks signed checkpoints from a small set of peers,
// demands a quorum agree on the committed root, then serves objects fetched
// on demand — every object checked against an inclusion proof before it is
// cached or returned. Storage cost is the working set plus ~100 bytes of
// heads, which is what makes a phone-sized node possible.
//
// The light node never evaluates trust: it serves exactly the envelopes a
// full relay would, after proving they belong to the agreed set. Consumers
// keep using verify.Graph locally (INV-5 preserved end to end).
package lightnode
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
)
// Config configures a light node.
type Config struct {
// Peers are relay base URLs, e.g. https://trust.n1ko.dev.
Peers []string
// PinKeys are hex Ed25519 relay public keys to trust. Empty enables
// trust-on-first-use: the first key seen for a peer is recorded.
PinKeys []string
// Quorum is how many distinct relay keys must agree on one root for the
// node to accept it. Zero means all peers.
Quorum int
// CacheDir stores fetched envelopes; empty disables persistence.
CacheDir string
// CacheMaxBytes caps the cache directory; oldest files evicted first.
CacheMaxBytes int64
// BFTValidators are hex public keys of the validator set. When set, the
// node exposes GET /v1/bft/certificate/{height} and verifies every
// certificate against exactly these keys before serving it.
BFTValidators []string
}
var (
// ErrNoQuorum means fewer agreeing keys than configured.
ErrNoQuorum = errors.New("lightnode: no quorum")
// ErrUnpinnedKey means TOFU is off and an unknown relay key answered.
ErrUnpinnedKey = errors.New("lightnode: unpinned relay key")
)
// Head is one verified checkpoint observation from one relay key.
type Head struct {
PublicKey string
ID string
Epoch uint64
Size uint64
Root [32]byte
Bytes []byte
Signature []byte
}
// Node is the light node core. Safe for concurrent use.
type Node struct {
cfg Config
http *http.Client
mu sync.Mutex
pins map[string]bool
heads map[string]*Head // relay pubkey -> newest verified head
decision *Head // quorum-approved head; nil until first success
lastErr error
wsOnce sync.Once
ws *wsNode
wsErr error
}
// WS returns the streaming subsystem, building it on first use.
func (n *Node) WS() (*wsNode, error) {
n.wsOnce.Do(func() {
n.ws, n.wsErr = newWSNode(n)
})
return n.ws, n.wsErr
}
// New constructs a node.
func New(cfg Config) *Node {
if cfg.Quorum <= 0 {
cfg.Quorum = len(cfg.Peers)
}
if cfg.CacheMaxBytes == 0 {
cfg.CacheMaxBytes = 256 << 20 // 256 MiB default working set
}
n := &Node{
cfg: cfg,
http: &http.Client{Timeout: 10 * time.Second},
pins: make(map[string]bool),
heads: make(map[string]*Head),
}
for _, k := range cfg.PinKeys {
n.pins[k] = true
}
return n
}
type wireHead struct {
Bytes string `json:"bytes"`
Signature string `json:"signature"`
ID string `json:"id"`
PublicKey string `json:"public_key"`
Checkpoint struct {
Epoch uint64 `json:"epoch"`
Size uint64 `json:"size"`
Root string `json:"root"`
} `json:"checkpoint"`
}
func (n *Node) fetchHead(ctx context.Context, peer string) (*Head, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/checkpoint/latest", nil)
if err != nil {
return nil, err
}
resp, err := n.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("lightnode: peer status " + resp.Status)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
if err != nil {
return nil, err
}
var w wireHead
if json.Unmarshal(raw, &w) != nil {
return nil, errors.New("lightnode: bad head json")
}
b, err := base64.StdEncoding.DecodeString(w.Bytes)
if err != nil {
return nil, errors.New("lightnode: bad head bytes")
}
cp, err := checkpoint.Decode(b)
if err != nil {
return nil, err
}
sig, err := base64.StdEncoding.DecodeString(w.Signature)
if err != nil {
return nil, errors.New("lightnode: bad signature encoding")
}
pub, err := hex.DecodeString(w.PublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnpinnedKey
}
if err := checkpoint.Verify(ed25519.PublicKey(pub), b, sig); err != nil {
return nil, err
}
var root [32]byte
rb, _ := hex.DecodeString(w.Checkpoint.Root)
copy(root[:], rb)
idSum := checkpoint.ID(b)
if len(w.ID) == 64 && hex.EncodeToString(idSum[:]) != w.ID {
return nil, errors.New("lightnode: head id mismatch")
}
return &Head{
PublicKey: w.PublicKey,
ID: w.ID,
Epoch: cp.Epoch,
Size: cp.Size,
Root: root,
Bytes: b,
Signature: sig,
}, nil
}
// Refresh polls every peer concurrently, records verified observations and
// recomputes the decision.
func (n *Node) Refresh(ctx context.Context) error {
type result struct {
head *Head
err error
}
ch := make(chan result, len(n.cfg.Peers))
for _, p := range n.cfg.Peers {
go func(peer string) {
h, err := n.fetchHead(ctx, peer)
ch <- result{h, err}
}(p)
}
n.mu.Lock()
defer n.mu.Unlock()
var firstErr error
for range n.cfg.Peers {
res := <-ch
if res.err != nil {
if firstErr == nil {
firstErr = res.err
}
continue
}
h := res.head
if !n.pins[h.PublicKey] {
if len(n.cfg.PinKeys) > 0 {
if firstErr == nil {
firstErr = ErrUnpinnedKey
}
continue
}
n.pins[h.PublicKey] = true // TOFU
}
if old, ok := n.heads[h.PublicKey]; !ok || h.Epoch >= old.Epoch {
n.heads[h.PublicKey] = h
}
}
err := n.redecideLocked()
if firstErr == nil {
firstErr = err
}
n.lastErr = firstErr
return firstErr
}
// redecideLocked requires the configured number of distinct relay keys to
// agree on one root and picks the highest-epoch observation among them.
func (n *Node) redecideLocked() error {
byRoot := make(map[[32]byte][]*Head)
for _, h := range n.heads {
byRoot[h.Root] = append(byRoot[h.Root], h)
}
var best []*Head
for _, group := range byRoot {
if len(group) > len(best) {
best = group
}
}
if len(best) < n.cfg.Quorum {
n.decision = nil
return ErrNoQuorum
}
sort.Slice(best, func(i, j int) bool { return best[i].Epoch > best[j].Epoch })
n.decision = best[0]
return nil
}
// Decision returns the currently accepted head, if any.
func (n *Node) Decision() *Head {
n.mu.Lock()
defer n.mu.Unlock()
return n.decision
}

View file

@ -1,358 +0,0 @@
package lightnode
// WebSocket mirroring. The node exposes the relay's streaming protocol to
// its own clients and feeds it from upstream relays:
//
// client ──ws──▶ light node ──ws──▶ pinned relays
//
// Every envelope received upstream is signature-checked before forwarding
// and deduplicated across peers by content ID. A freshly streamed object is
// usually younger than the newest quorum-agreed checkpoint, so it cannot yet
// carry an inclusion proof; such objects are forwarded marked
// "verified": false and queued. The queue is re-proven against each new
// agreed root purely as bookkeeping (and dropped once covered or exhausted):
// clients that need proof-backed bytes should re-fetch /v1/objects/{id},
// which only ever serves proof-verified content. Streaming is news,
// fetching is evidence.
//
// Subscriptions demanded downstream are reconciled onto upstream
// connections when they are (re)established; a subscription added while a
// connection is already up takes effect at its next reconnect. Documented
// v1 simplification.
import (
"context"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
const (
lnSendBuffer = 64
lnMaxConns = 64
lnMaxSubsPerConn = 16
lnPendingCap = 512
lnSeenCap = 10000
)
type lnSub struct {
channel, key string
}
type lnClient struct {
subs []lnSub
send chan []byte
done chan struct{}
evicted bool
}
type lnHub struct {
mu sync.Mutex
clients map[*lnClient]struct{}
demand map[lnSub]int // refcounted subscriptions demanded downstream
kick chan struct{} // wakes the manager so it can dial missing peers
}
func newLnHub() *lnHub {
return &lnHub{
clients: make(map[*lnClient]struct{}),
demand: make(map[lnSub]int),
kick: make(chan struct{}, 1),
}
}
// evict removes a client and drops its demand contributions.
func (h *lnHub) evict(c *lnClient) {
h.mu.Lock()
if !c.evicted {
c.evicted = true
close(c.done)
}
delete(h.clients, c)
for _, s := range c.subs {
k := lnSub{s.channel, s.key}
h.demand[k]--
if h.demand[k] <= 0 {
delete(h.demand, k)
}
}
h.mu.Unlock()
h.signal()
}
func (h *lnHub) signal() {
select {
case h.kick <- struct{}{}:
default:
}
}
type pendingEvent struct {
channel, key, objID string
attempts int
}
type wsNode struct {
node *Node
auth *upstreamAuth
hub *lnHub
mu sync.Mutex
seen map[string]struct{}
pending []pendingEvent
upstreamLive map[string]bool
upstreamStarting map[string]bool
}
func newWSNode(n *Node) (*wsNode, error) {
auth, err := newUpstreamAuth(n.cfg.CacheDir, n.http)
if err != nil {
return nil, err
}
return &wsNode{
node: n,
auth: auth,
hub: newLnHub(),
seen: make(map[string]struct{}),
upstreamLive: make(map[string]bool),
upstreamStarting: make(map[string]bool),
}, nil
}
// RunWS maintains one upstream stream per peer while subscriptions are
// demanded downstream. It blocks until ctx ends.
func (w *wsNode) RunWS(ctx context.Context) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.hub.kick:
case <-ticker.C:
}
w.flushPending()
w.connectAll(ctx)
}
}
// UpstreamReady reports whether at least one upstream stream is live. It is
// a convenience for operators and tests: events published before this point
// were never broadcast anywhere, so waiting on it avoids missing early ones.
func (w *wsNode) UpstreamReady() bool {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.upstreamLive) > 0
}
func (w *wsNode) hasDemand() bool {
w.hub.mu.Lock()
defer w.hub.mu.Unlock()
return len(w.hub.demand) > 0
}
func (w *wsNode) connectAll(ctx context.Context) {
if !w.hasDemand() {
return
}
for _, peer := range w.node.cfg.Peers {
w.mu.Lock()
live := w.upstreamLive[peer] || w.upstreamStarting[peer]
if !live {
w.upstreamStarting[peer] = true
}
w.mu.Unlock()
if live {
continue
}
go func(peer string) {
defer func() {
w.mu.Lock()
delete(w.upstreamStarting, peer)
delete(w.upstreamLive, peer)
w.mu.Unlock()
}()
w.serveUpstream(ctx, peer)
}(peer)
}
}
// serveUpstream runs one connection lifecycle: authenticate over HTTP,
// upgrade with the token, subscribe to everything currently demanded, pump
// events until the connection or ctx dies. Reconnection happens on the next
// manager tick because demand persists.
func (w *wsNode) serveUpstream(ctx context.Context, peer string) {
token, err := w.auth.session(ctx, peer)
if err != nil {
return
}
wsURL := strings.Replace(peer, "http", "ws", 1) + "/v1/ws?token=" + token
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
w.auth.invalidate(peer)
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
w.hub.mu.Lock()
for k := range w.hub.demand {
raw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": k.channel, "key": k.key})
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
w.hub.mu.Unlock()
return
}
}
w.hub.mu.Unlock()
w.mu.Lock()
w.upstreamLive[peer] = true
w.mu.Unlock()
for {
typ, raw, err := conn.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
w.handleUpstreamEvent(raw)
}
}
func handleUpstreamEvent(w *wsNode, raw []byte) { w.handleUpstreamEvent(raw) }
func (w *wsNode) handleUpstreamEvent(raw []byte) {
var ev struct {
Event string `json:"event"`
Channel string `json:"channel"`
Key string `json:"key"`
ObjectID string `json:"object_id"`
Envelope *struct {
TCE []byte `json:"tce"`
Signature []byte `json:"signature"`
} `json:"envelope"`
}
if json.Unmarshal(raw, &ev) != nil || ev.Event != "object" || ev.Envelope == nil {
return
}
objID := ev.ObjectID
if len(objID) != 64 || ev.Channel == "" || ev.Key == "" {
return
}
w.mu.Lock()
if _, dup := w.seen[objID]; dup {
w.mu.Unlock()
return // another peer already delivered this exact object
}
w.seen[objID] = struct{}{}
if len(w.seen) > lnSeenCap {
w.seen = make(map[string]struct{}) // coarse reset; dedupe is best-effort
}
w.mu.Unlock()
env := &transport.Envelope{TCE: ev.Envelope.TCE, Signature: ev.Envelope.Signature}
typName, err := env.Verify()
if err != nil || !validLNChannel(typName) {
return // never forward an envelope that does not verify locally
}
w.dispatch(ev.Channel, ev.Key, objID, ev.Envelope.TCE, ev.Envelope.Signature)
}
func validLNChannel(typeName string) bool {
switch typeName {
case "claim", "request", "response", "revocation":
return true
}
return false
}
// dispatch pushes to matching downstream clients and queues the object id
// for proof bookkeeping against the next quorum-agreed root.
func (w *wsNode) dispatch(channel, key, objID string, tceBytes, sig []byte) {
payload, _ := json.Marshal(map[string]any{
"event": "object",
"channel": channel,
"key": key,
"object_id": objID,
"envelope": map[string]any{"tce": tceBytes, "signature": sig},
"verified": false,
})
w.hub.mu.Lock()
for c := range w.hub.clients {
match := false
for _, s := range c.subs {
if s.channel == channel && s.key == key {
match = true
break
}
}
if !match || c.evicted {
continue
}
select {
case c.send <- payload:
default:
// Slow client under lock: close-once signalling, no send/close race.
c.evicted = true
close(c.done)
delete(w.hub.clients, c)
}
}
w.hub.mu.Unlock()
w.mu.Lock()
if len(w.pending) < lnPendingCap {
w.pending = append(w.pending, pendingEvent{channel: channel, key: key, objID: objID})
}
w.mu.Unlock()
}
// flushPending re-proves queued object ids against the newest agreed root,
// keeping only ids that are still unproven and under the attempt budget.
func (w *wsNode) flushPending() {
root, ok := w.node.decisionRootOK()
if !ok {
return
}
w.mu.Lock()
defer w.mu.Unlock()
kept := w.pending[:0]
for _, p := range w.pending {
idb, err := hex.DecodeString(p.objID)
var id [32]byte
if err == nil && len(idb) == 32 {
copy(id[:], idb)
if proof, err := w.node.inclusionProofFromPeer(p.objID); err == nil &&
smt.VerifyInclusion(root, id, proof) {
continue // now covered by the quorum-agreed checkpoint
}
}
p.attempts++
if p.attempts < 3 {
kept = append(kept, p)
}
}
w.pending = kept
}
// websocketAccept upgrades with permissive origins: the light node is a
// local mirror whose authority comes from its pinned peers, not from the
// browser that talks to it.
func websocketAccept(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
return websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
}
var _ = http.StatusText

View file

@ -1,168 +0,0 @@
// Package pow implements the relay's proof-of-work admission control.
//
// PoW is a transport-layer anti-abuse mechanism: it taxes anonymous flooding
// without taxing honest low-volume clients, whose cost is one short solver
// loop. It never enters TCE bytes, never affects any signature, and is
// invisible to verifiers (PROTOCOL.md §9 keeps server-side data out of signed
// statements).
//
// Scheme. The relay issues a single-use 32-byte challenge key. The client
// finds a uint32 counter such that
//
// BLAKE3_keyed(key, Domain || target || counter_be)
//
// has at least `difficulty` leading zero bits. For object storage the target
// is the object's content ID, binding the proof to that exact submission; for
// authentication it is 32 zero bytes, because single-use consumption of a
// fresh server-chosen key already carries the protection. Verification is one
// hash call, so the asymmetry is total.
package pow
import (
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
blake3 "lukechampine.com/blake3"
)
// Domain separates these hashes from every other use of BLAKE3 anywhere in
// the tree. It is part of the hashed message, not the key.
const Domain = "trust.n1ko.dev/pow/1"
// KeySize is the exact size of a challenge key.
const KeySize = 32
// TargetSize is the exact size of the binding target.
const TargetSize = 32
// MaxDifficulty is the highest configurable difficulty, in leading zero bits.
//
// Solving is defined over a uint32 counter, so a difficulty d has an expected
// work of 2^d hashes and fails outright with probability exp(-2^(32-d)) once
// the counter space is exhausted: negligible up to 30 bits, material beyond.
// Configuration above MaxDifficulty is clamped.
const MaxDifficulty = 30
// ErrBadKey is returned when hex text is not a valid challenge key.
var ErrBadKey = errors.New("pow: malformed challenge key")
// Key is a server-issued challenge key. It exists to keep keys out of code
// paths expecting arbitrary bytes.
type Key [KeySize]byte
// NewKey draws a fresh challenge key from the CSPRNG.
func NewKey() Key {
var k Key
if _, err := rand.Read(k[:]); err != nil {
panic("pow: rand: " + err.Error())
}
return k
}
// ParseKey decodes lowercase hex challenge-key text.
func ParseKey(s string) (Key, error) {
var k Key
if len(s) != KeySize*2 {
return k, ErrBadKey
}
b, err := hex.DecodeString(s)
if err != nil {
return k, ErrBadKey
}
copy(k[:], b)
return k, nil
}
// String renders the key as lowercase hex.
func (k Key) String() string { return hex.EncodeToString(k[:]) }
// Sum computes the proof hash for one candidate solution.
func Sum(key Key, target [TargetSize]byte, counter uint32) [32]byte {
h := blake3.New(32, key[:])
buf := make([]byte, 0, len(Domain)+TargetSize+4)
buf = append(buf, Domain...)
buf = append(buf, target[:]...)
buf = binary.BigEndian.AppendUint32(buf, counter)
h.Write(buf)
var out [32]byte
h.Sum(out[:0])
return out
}
// LeadingZeroBits counts the zero bits at the top of sum.
func LeadingZeroBits(sum [32]byte) int {
n := 0
for _, b := range sum {
if b == 0 {
n += 8
continue
}
for b&0x80 == 0 {
n++
b <<= 1
}
break
}
return n
}
// MeetsTarget reports whether sum satisfies the difficulty in leading zero
// bits. A difficulty of zero or less is always satisfied; a difficulty above
// 256 never is.
func MeetsTarget(sum [32]byte, difficulty int) bool {
return difficulty <= 0 || LeadingZeroBits(sum) >= difficulty
}
// Verify checks one claimed solution with a single hash call.
func Verify(key Key, target [TargetSize]byte, difficulty int, counter uint32) bool {
if difficulty < 0 || difficulty > MaxDifficulty {
return false
}
return MeetsTarget(Sum(key, target, counter), difficulty)
}
// Solve finds the smallest counter meeting the difficulty. The boolean is
// false if the uint32 counter space is exhausted, which for valid
// configurations (difficulty ≤ MaxDifficulty) happens only with the small
// probability derived from the birthday-style tail.
func Solve(key Key, target [TargetSize]byte, difficulty int) (uint32, bool) {
if difficulty <= 0 {
return 0, true
}
var c uint32
for {
if MeetsTarget(Sum(key, target, c), difficulty) {
return c, true
}
if c == ^uint32(0) {
return 0, false
}
c++
}
}
// Proof is one solved challenge as it travels beside an envelope.
type Proof struct {
Key Key `json:"-"`
KeyHex string `json:"key"`
Counter uint32 `json:"counter"`
}
// ParseProof validates and decodes the wire form of a proof.
func ParseProof(keyHex string, counter uint32) (Proof, error) {
k, err := ParseKey(keyHex)
if err != nil {
return Proof{}, fmt.Errorf("pow: %w", err)
}
return Proof{Key: k, KeyHex: keyHex, Counter: counter}, nil
}
// Equal reports whether two proofs carry the same fields, comparing key bytes
// in constant time.
func (p Proof) Equal(other Proof) bool {
return subtle.ConstantTimeCompare(p.Key[:], other.Key[:]) == 1 && p.Counter == other.Counter
}

View file

@ -1,216 +0,0 @@
package pow_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
blake3 "lukechampine.com/blake3"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
)
func testKey(seed byte) pow.Key {
var k pow.Key
for i := range k {
k[i] = seed + byte(i)
}
return k
}
func testTarget(seed byte) [32]byte {
var t [32]byte
for i := range t {
t[i] = seed * byte(i+1)
}
return t
}
func TestSolveAndVerifyRoundTrip(t *testing.T) {
key := testKey(0x10)
target := testTarget(0x20)
const difficulty = 12
counter, ok := pow.Solve(key, target, difficulty)
if !ok {
t.Fatal("solve failed")
}
if !pow.Verify(key, target, difficulty, counter) {
t.Fatal("verify rejected own solution")
}
sum := pow.Sum(key, target, counter)
if n := pow.LeadingZeroBits(sum); n < difficulty {
t.Fatalf("solution has %d leading zero bits, want >= %d", n, difficulty)
}
}
func TestVerifyRejectsWrongInputs(t *testing.T) {
key := testKey(0x01)
target := testTarget(0x02)
const difficulty = 10
counter, ok := pow.Solve(key, target, difficulty)
if !ok {
t.Fatal("solve failed")
}
cases := []struct {
name string
key pow.Key
target [32]byte
difficulty int
counter uint32
}{
{"wrong key", testKey(0xFF), target, difficulty, counter},
{"wrong target", key, testTarget(0xFE), difficulty, counter},
{"wrong counter", key, target, difficulty, counter + 1},
{"difficulty above max", key, target, pow.MaxDifficulty + 1, counter},
}
for _, tc := range cases {
if pow.Verify(tc.key, tc.target, tc.difficulty, tc.counter) {
t.Errorf("%s: verify accepted invalid proof", tc.name)
}
}
}
func TestSolveDeterministicSmallestCounter(t *testing.T) {
key := testKey(0x33)
target := testTarget(0x44)
first, ok := pow.Solve(key, target, 8)
if !ok {
t.Fatal("solve failed")
}
second, _ := pow.Solve(key, target, 8)
if first != second {
t.Fatalf("solver nondeterministic: %d vs %d", first, second)
}
// The smallest counter is a real lower bound: nothing below verifies.
for c := uint32(0); c < first; c++ {
if pow.Verify(key, target, 8, c) {
t.Fatalf("counter %d verifies but solver returned %d", c, first)
}
}
}
func TestLeadingZeroBitsBoundaries(t *testing.T) {
cases := []struct {
b byte
want int
}{
{0x80, 0},
{0x40, 1},
{0x01, 7},
{0x00, 8},
}
var sum [32]byte
for _, tc := range cases {
for i := range sum {
sum[i] = 0xFF
}
sum[0] = tc.b
if got := pow.LeadingZeroBits(sum); got != tc.want {
t.Errorf("byte %02x: got %d leading zero bits, want %d", tc.b, got, tc.want)
}
}
for i := range sum {
sum[i] = 0
}
if got := pow.LeadingZeroBits(sum); got != 256 {
t.Errorf("all-zero sum: got %d, want 256", got)
}
}
func TestMeetsTargetDifficultySemantics(t *testing.T) {
var zero [32]byte
var full [32]byte
for i := range full {
full[i] = 0xFF
}
if !pow.MeetsTarget(zero, 256) {
t.Error("all-zero hash must meet any representable difficulty")
}
if pow.MeetsTarget(full, 1) {
t.Error("all-ff hash must not meet difficulty 1")
}
if !pow.MeetsTarget(full, 0) || !pow.MeetsTarget(full, -5) {
t.Error("difficulty <= 0 must always pass (disabled)")
}
}
func TestSumMatchesManualConstruction(t *testing.T) {
// Recompute the hash input independently and compare against Sum.
key := testKey(0x55)
target := testTarget(0x66)
const counter = 0x01020304
h := blake3.New(32, key[:])
h.Write([]byte(pow.Domain))
h.Write(target[:])
var cb [4]byte
binary.BigEndian.PutUint32(cb[:], counter)
h.Write(cb[:])
var want [32]byte
copy(want[:], h.Sum(nil))
got := pow.Sum(key, target, counter)
if !bytes.Equal(got[:], want[:]) {
t.Fatalf("Sum mismatch:\n got %x\nwant %x", got, want)
}
}
func TestKeyParseAndEqual(t *testing.T) {
key := testKey(0x77)
parsed, err := pow.ParseKey(key.String())
if err != nil {
t.Fatal(err)
}
proof := pow.Proof{Key: parsed, KeyHex: parsed.String(), Counter: 1}
same, _ := pow.ParseKey(key.String())
if !proof.Equal(pow.Proof{Key: same, KeyHex: same.String(), Counter: 1}) {
t.Error("equal proofs compared unequal")
}
if proof.Equal(pow.Proof{Key: parsed, KeyHex: parsed.String(), Counter: 2}) {
t.Error("different counters compared equal")
}
bad := []string{"", "00", "ZZ", key.String() + "00"}
for _, s := range bad {
if _, err := pow.ParseKey(s); err == nil {
t.Errorf("ParseKey(%q) accepted malformed key", s)
}
}
}
func TestNewKeyUnique(t *testing.T) {
seen := make(map[pow.Key]struct{})
for i := 0; i < 100; i++ {
k := pow.NewKey()
if _, dup := seen[k]; dup {
t.Fatal("CSPRNG produced duplicate key")
}
seen[k] = struct{}{}
}
}
// Solve at MaxDifficulty must terminate within the uint32 space with
// overwhelming probability; this bounds runtime while exercising deep loops.
func TestSolveHighDifficultyTerminates(t *testing.T) {
key := testKey(0x99)
target := testTarget(0xAA)
_, ok := pow.Solve(key, target, 20)
if !ok {
t.Skip("counter space exhausted for this key/target pair")
}
}
func BenchmarkVerify(b *testing.B) {
key := testKey(0xAB)
target := testTarget(0xCD)
for i := 0; b.Loop(); i++ {
pow.Verify(key, target, 22, uint32(i%math.MaxUint32))
}
}

View file

@ -1,133 +0,0 @@
package pow_test
import (
"encoding/hex"
"encoding/json"
"os"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
)
const powVectorsPath = "../../testdata/vectors/pow_vectors.json"
type powVectorFile struct {
Domain string `json:"domain"`
HashSpec string `json:"hash_spec"`
MaxDifficulty int `json:"max_difficulty"`
Vectors []struct {
Name string `json:"name"`
KeyHex string `json:"key_hex"`
TargetHex string `json:"target_hex"`
Difficulty int `json:"difficulty"`
Counter uint32 `json:"counter"`
SumHex string `json:"sum_hex"`
LeadingZeroBits int `json:"leading_zero_bits"`
} `json:"vectors"`
Rejects []struct {
Name string `json:"name"`
KeyHex string `json:"key_hex"`
TargetHex string `json:"target_hex"`
Difficulty int `json:"difficulty"`
Counter uint32 `json:"counter"`
Reason string `json:"reason"`
} `json:"rejects"`
ConfigRejects []struct {
Name string `json:"name"`
Difficulty int `json:"difficulty"`
Reason string `json:"reason"`
} `json:"config_rejects"`
}
func loadPoWVectors(t *testing.T) *powVectorFile {
t.Helper()
b, err := os.ReadFile(powVectorsPath)
if err != nil {
t.Fatalf("read vectors: %v", err)
}
var vf powVectorFile
if err := json.Unmarshal(b, &vf); err != nil {
t.Fatalf("parse vectors: %v", err)
}
return &vf
}
// TestPoWVectorsAgainstReference reproduces every frozen vector produced by
// the independent Python reference. A disagreement here means one of the two
// implementations misread docs/POW.md.
func TestPoWVectorsAgainstReference(t *testing.T) {
vf := loadPoWVectors(t)
if vf.Domain != pow.Domain {
t.Fatalf("domain drift: file %q, code %q", vf.Domain, pow.Domain)
}
if vf.MaxDifficulty != pow.MaxDifficulty {
t.Fatalf("max difficulty drift: file %d, code %d", vf.MaxDifficulty, pow.MaxDifficulty)
}
for _, v := range vf.Vectors {
t.Run(v.Name, func(t *testing.T) {
key, err := pow.ParseKey(v.KeyHex)
if err != nil {
t.Fatal(err)
}
var target [pow.TargetSize]byte
tb, err := hex.DecodeString(v.TargetHex)
if err != nil || len(tb) != pow.TargetSize {
t.Fatalf("bad target hex: %v", err)
}
copy(target[:], tb)
got := pow.Sum(key, target, v.Counter)
want, _ := hex.DecodeString(v.SumHex)
if hex.EncodeToString(got[:]) != v.SumHex {
t.Fatalf("sum mismatch:\n got %x\nwant %x", got[:], want)
}
if n := pow.LeadingZeroBits(got); n < v.Difficulty {
t.Fatalf("vector claims validity at difficulty %d but hash has %d bits", v.Difficulty, n)
}
if got2 := pow.LeadingZeroBits(got); got2 != v.LeadingZeroBits {
t.Fatalf("leading zero bits: got %d, want %d", got2, v.LeadingZeroBits)
}
if !pow.Verify(key, target, v.Difficulty, v.Counter) {
t.Fatal("Verify rejected a reference-valid solution")
}
// The recorded counter must be the smallest solution: nothing
// below it may verify. Solving again and comparing enforces both
// minimality and solver determinism.
solved, ok := pow.Solve(key, target, v.Difficulty)
if !ok {
t.Fatal("Solve exhausted counter space on a solvable vector")
}
if solved != v.Counter {
t.Fatalf("Solve returned %d, reference minimal counter is %d", solved, v.Counter)
}
})
}
}
func TestPoWRejectsAgainstReference(t *testing.T) {
vf := loadPoWVectors(t)
for _, r := range vf.Rejects {
t.Run(r.Name, func(t *testing.T) {
key, err := pow.ParseKey(r.KeyHex)
if err != nil {
t.Fatal(err)
}
var target [pow.TargetSize]byte
tb, _ := hex.DecodeString(r.TargetHex)
copy(target[:], tb)
if pow.Verify(key, target, r.Difficulty, r.Counter) {
t.Fatalf("reject %q verifies", r.Name)
}
})
}
for _, c := range vf.ConfigRejects {
t.Run(c.Name, func(t *testing.T) {
if c.Difficulty > pow.MaxDifficulty {
return // configuration layer must clamp or refuse; unit scope ends here
}
})
}
}

View file

@ -1,216 +0,0 @@
package server
// BFT validator mode. Opt-in: a relay that runs with -bft-validators also
// acts as a finality validator using its existing transport key. Non-validator
// relays and light nodes stay pure gossip participants and can still verify
// any published certificate offline against the public validator set.
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
)
func bytesReader(raw []byte) *bytes.Reader { return bytes.NewReader(raw) }
// BFTConfig configures validator participation.
type BFTConfig struct {
// ValidatorKeys are hex Ed25519 public keys of every validator,
// index-aligned with ValidatorURLs.
ValidatorKeys []string
// ValidatorURLs are base URLs used to reach each validator.
ValidatorURLs []string
// RoundTimeout drives proposer rotation when a round stalls.
RoundTimeout time.Duration
}
type bftState struct {
set *bft.Set
me int // -1 when this relay is not a validator
val *bft.Validator
key ed25519.PrivateKey
urls []string
timeout time.Duration
client *http.Client
lastProg time.Time
mu sync.Mutex
}
// WithBFT enables validator participation. The relay's transport key doubles
// as its validator key: one key, one role, no new secrets.
func WithBFT(cfg BFTConfig) Option {
return func(s *Server) {
if s.ckpt == nil || len(cfg.ValidatorKeys) == 0 {
return // finality needs checkpoints to exist at all
}
set, err := bft.NewSet(cfg.ValidatorKeys, cfg.ValidatorURLs)
if err != nil {
return
}
st := &bftState{
set: set,
me: -1,
urls: cfg.ValidatorURLs,
timeout: cfg.RoundTimeout,
client: &http.Client{Timeout: 5 * time.Second},
}
myPub := s.ckpt.key.Public().(ed25519.PublicKey)
for i := range set.PubKeys {
if string(set.PubKeys[i]) == string(myPub) {
st.me = i
break
}
}
if st.me >= 0 {
st.key = s.ckpt.key
st.val = newMachineFor(s, st)
}
s.bft = st
}
}
// newMachineFor builds the state machine wired to this relay's heads and
// HTTP fan-out.
func newMachineFor(s *Server, st *bftState) *bft.Validator {
latest := func() ([32]byte, bool) {
h, ok := s.ckpt.latestHead()
if !ok {
return [32]byte{}, false
}
idb, err := hex.DecodeString(h.ID)
if err != nil || len(idb) != 32 {
return [32]byte{}, false
}
var id [32]byte
copy(id[:], idb)
return id, true
}
broadcast := func(path string, payload any) {
for _, u := range st.urls {
go func(url string) {
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, url+"/v1/bft/"+path, bytesReader(raw))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := st.client.Do(req)
if err == nil {
resp.Body.Close()
}
}(u)
}
}
return bft.NewValidator(st.set, st.me, st.key, latest, broadcast)
}
// StartBFT runs the round driver until ctx ends.
func (s *Server) StartBFT(ctx context.Context) {
if s.bft == nil || s.bft.val == nil {
return
}
timeout := s.bft.timeout
if timeout <= 0 {
timeout = 2 * time.Second
}
ticker := time.NewTicker(timeout / 2)
defer ticker.Stop()
s.bft.lastProg = time.Now()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.bft.mu.Lock()
h := s.bft.val.Height()
progressed := time.Since(s.bft.lastProg) < timeout
r := uint64(0)
if !progressed {
r = (uint64(time.Now().UnixNano()) % uint64(len(s.bft.set.PubKeys)))
s.bft.lastProg = time.Now()
}
s.bft.mu.Unlock()
if !progressed {
s.bft.val.StartRound(h, r%uint64(len(s.bft.set.PubKeys)))
} else {
s.bft.val.StartRound(h, 0)
}
}
}
}
// ------------------------------------------------------------------ handlers
func (s *Server) handleBFTProposal(w http.ResponseWriter, r *http.Request) {
var signed bft.Signed
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
writeErr(w, http.StatusBadRequest, "bad proposal body")
return
}
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
s.bft.val.OnProposal(&signed)
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleBFTVote(w http.ResponseWriter, r *http.Request) {
var signed bft.Signed
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
writeErr(w, http.StatusBadRequest, "bad vote body")
return
}
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
s.bft.val.OnVote(&signed)
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleBFTState(w http.ResponseWriter, r *http.Request) {
if s.bft == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
out := map[string]any{"enabled": s.bft.me >= 0}
if s.bft.val != nil {
out["height"] = s.bft.val.Height()
lf := s.bft.val.LastFinalized()
out["last_finalized"] = hex.EncodeToString(lf[:])
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleBFTCertificate(w http.ResponseWriter, r *http.Request) {
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
h, err := strconv.ParseUint(r.PathValue("height"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad height")
return
}
cert := s.bft.val.Certificate(h)
if cert == nil {
writeErr(w, http.StatusNotFound, "no certificate")
return
}
writeJSON(w, http.StatusOK, cert)
}
// SetBFT applies BFT configuration after construction (used by main before
// serving starts).
func (s *Server) SetBFT(cfg BFTConfig) {
WithBFT(cfg)(s)
}

View file

@ -1,397 +0,0 @@
package server
// The checkpoint service signs the relay's object-set commitment on a
// schedule: every interval, or as soon as enough new objects have arrived,
// whichever comes first. It owns the relay's transport key — the one key the
// relay holds, whose entire power is to describe its own storage
// (docs/TRUST-MODEL.md, INV-1 amendment). Statement signing stays impossible:
// this file never imports identity/signer and the key never touches TCE.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
const (
ckptSeedFile = "relay_key.seed" // relay transport seed inside data dir
ckptMetaFile = "checkpoint_meta.json" // persisted epoch continuity
ckptHistoryPrefix = "checkpoint-" // per-epoch head files
ckptMaxHistory = 512 // heads kept in memory
)
// CheckpointConfig configures the scheduler.
type CheckpointConfig struct {
// Interval is the maximum time between checkpoints when the set changed.
Interval time.Duration
// EveryN signs earlier once this many new objects arrived.
// 0 falls back to Interval alone; 1 checkpoints every change.
EveryN int
}
type checkpointState struct {
mu sync.Mutex
key ed25519.PrivateKey
pubHex string
store *Store
metrics *Metrics
interval time.Duration
everyN int
dir string // empty in in-memory mode: nothing persists
epoch uint64
lastHash [32]byte // ID of most recent canonical bytes; zeros before genesis
dirty int
lastSign time.Time
heads map[uint64]signedHead
latest uint64
}
// signedHead is one published checkpoint with its signature.
type signedHead struct {
Bytes []byte `json:"bytes"`
Signature []byte `json:"signature"`
ID string `json:"id"`
}
func (h *signedHead) decode() (*checkpoint.Checkpoint, error) {
return checkpoint.Decode(h.Bytes)
}
// loadRelayKey reads or creates the transport seed.
func loadRelayKey(dir string) (ed25519.PrivateKey, error) {
if dir == "" {
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
}
path := filepath.Join(dir, ckptSeedFile)
if seed, err := os.ReadFile(path); err == nil && len(seed) == ed25519.SeedSize {
return ed25519.NewKeyFromSeed(seed), nil
}
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, seed, 0o600); err != nil {
return nil, err
}
return ed25519.NewKeyFromSeed(seed), nil
}
// WithCheckpoints enables the header chain. An empty dataDir means the relay
// runs in-memory: heads exist only while the process lives.
func WithCheckpoints(dataDir string, cfg CheckpointConfig) Option {
return func(s *Server) {
key, err := loadRelayKey(dataDir)
if err != nil {
// A relay that cannot hold its transport key still serves; it
// simply publishes no chain.
s.ckpt = nil
return
}
if cfg.Interval <= 0 {
cfg.Interval = time.Minute
}
st := &checkpointState{
key: key,
pubHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
store: s.store,
metrics: s.metrics,
interval: cfg.Interval,
everyN: cfg.EveryN,
dir: dataDir,
heads: make(map[uint64]signedHead),
}
st.loadMeta()
st.loadHistory()
s.ckpt = st
}
}
// metaJSON is the persisted epoch continuity record.
type metaJSON struct {
Epoch uint64 `json:"epoch"`
LastHash string `json:"last_hash"`
}
func (c *checkpointState) loadMeta() {
if c.dir == "" {
return
}
raw, err := os.ReadFile(filepath.Join(c.dir, ckptMetaFile))
if err != nil {
return
}
var m metaJSON
if json.Unmarshal(raw, &m) != nil || m.Epoch == 0 {
return
}
hash, err := hex.DecodeString(m.LastHash)
if err != nil || len(hash) != 32 {
return
}
c.epoch = m.Epoch
copy(c.lastHash[:], hash)
}
func (c *checkpointState) saveMetaLocked() error {
if c.dir == "" {
return nil
}
raw, _ := json.Marshal(metaJSON{Epoch: c.epoch, LastHash: hex.EncodeToString(c.lastHash[:])})
tmp := filepath.Join(c.dir, ckptMetaFile+".tmp")
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(c.dir, ckptMetaFile))
}
func (c *checkpointState) loadHistory() {
if c.dir == "" {
return
}
entries, err := os.ReadDir(c.dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if len(name) <= len(ckptHistoryPrefix)+len(".json") {
continue
}
if name[:len(ckptHistoryPrefix)] != ckptHistoryPrefix || name[len(name)-5:] != ".json" {
continue
}
ep, err := strconv.ParseUint(name[len(ckptHistoryPrefix):len(name)-5], 10, 64)
if err != nil || ep == 0 {
continue
}
raw, err := os.ReadFile(filepath.Join(c.dir, name))
if err != nil {
continue
}
var h signedHead
if json.Unmarshal(raw, &h) != nil {
continue
}
if _, err := h.decode(); err != nil {
continue
}
c.heads[ep] = h
if ep > c.latest {
c.latest = ep
}
}
}
// notify records that the set changed and may trigger an immediate sign.
func (s *Server) notifyCheckpoint() {
if s.ckpt == nil {
return
}
s.ckpt.mu.Lock()
s.ckpt.dirty++
fire := s.ckpt.everyN > 0 && s.ckpt.dirty >= s.ckpt.everyN
s.ckpt.mu.Unlock()
if fire {
s.ckpt.sign()
}
}
// sign builds, signs and stores one head if anything changed.
func (c *checkpointState) sign() (*signedHead, bool) {
c.mu.Lock()
defer c.mu.Unlock()
root := c.store.TrieRoot()
size := c.store.TrieSize()
if size == 0 && c.epoch == 0 {
return nil, false // nothing to commit to yet
}
cp := &checkpoint.Checkpoint{
Epoch: c.epoch + 1,
Size: size,
Root: root,
Prev: c.lastHash,
CreatedAt: uint64(now().Unix()),
}
b, err := cp.Encode()
if err != nil {
return nil, false
}
sig, err := checkpoint.Sign(c.key, b)
if err != nil {
return nil, false
}
id := checkpoint.ID(b)
head := signedHead{Bytes: b, Signature: sig, ID: hex.EncodeToString(id[:])}
c.epoch = cp.Epoch
c.latest = cp.Epoch
c.lastHash = id
c.dirty = 0
c.lastSign = now()
c.heads[cp.Epoch] = head
delete(c.heads, cp.Epoch-ckptMaxHistory)
if c.metrics != nil {
c.metrics.checkpointsSigned.Add(1)
c.metrics.trieSize.Store(size)
}
if c.dir != "" {
raw, _ := json.Marshal(head)
path := filepath.Join(c.dir, fmt.Sprintf("%s%d.json", ckptHistoryPrefix, cp.Epoch))
if os.WriteFile(path+".tmp", raw, 0o600) == nil {
_ = os.Rename(path+".tmp", path)
}
_ = c.saveMetaLocked()
}
return &head, true
}
// latest returns the newest head, if any.
func (c *checkpointState) latestHead() (signedHead, bool) {
c.mu.Lock()
defer c.mu.Unlock()
h, ok := c.heads[c.latest]
return h, ok
}
func (c *checkpointState) headByEpoch(ep uint64) (signedHead, bool) {
c.mu.Lock()
defer c.mu.Unlock()
h, ok := c.heads[ep]
return h, ok
}
// run loops until ctx ends, signing when the interval elapsed with pending
// changes.
func (c *checkpointState) run(ctx context.Context) {
ticker := time.NewTicker(c.interval / 4)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.mu.Lock()
pending := c.dirty > 0 && now().Sub(c.lastSign) >= c.interval
c.mu.Unlock()
if pending {
c.sign()
}
}
}
}
// StartCheckpoints launches the signing loop. Call StopCheckpoints on shutdown.
func (s *Server) StartCheckpoints(ctx context.Context) {
if s.ckpt == nil {
return
}
go s.ckpt.run(ctx)
}
// ------------------------------------------------------------ HTTP handlers
func (s *Server) handleCheckpointLatest(w http.ResponseWriter, r *http.Request) {
s.serveHead(w, func() (signedHead, bool) { return s.ckpt.latestHead() })
}
func (s *Server) handleCheckpointEpoch(w http.ResponseWriter, r *http.Request) {
ep, err := strconv.ParseUint(r.PathValue("epoch"), 10, 64)
if err != nil || ep == 0 {
writeErr(w, http.StatusBadRequest, "bad epoch")
return
}
s.serveHead(w, func() (signedHead, bool) { return s.ckpt.headByEpoch(ep) })
}
func (s *Server) serveHead(w http.ResponseWriter, get func() (signedHead, bool)) {
if s.ckpt == nil {
writeErr(w, http.StatusNotImplemented, "checkpoints disabled")
return
}
h, ok := get()
if !ok {
writeErr(w, http.StatusNotFound, "no checkpoint")
return
}
cp, err := h.decode()
if err != nil {
writeErr(w, http.StatusInternalServerError, "corrupt head")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"bytes": base64.StdEncoding.EncodeToString(h.Bytes),
"signature": base64.StdEncoding.EncodeToString(h.Signature),
"id": h.ID,
"public_key": s.ckpt.pubHex,
"checkpoint": map[string]any{
"epoch": cp.Epoch,
"size": cp.Size,
"root": hex.EncodeToString(cp.Root[:]),
"prev": hex.EncodeToString(cp.Prev[:]),
"created_at": cp.CreatedAt,
},
})
}
var (
errProofPresentRequested = errors.New("server: object exists; absence proof unavailable")
errProofAbsentRequested = errors.New("server: object unknown; inclusion proof unavailable")
)
func (s *Server) handleProofObject(w http.ResponseWriter, r *http.Request) {
s.serveProof(w, r, true)
}
func (s *Server) handleProofAbsent(w http.ResponseWriter, r *http.Request) {
s.serveProof(w, r, false)
}
func (s *Server) serveProof(w http.ResponseWriter, r *http.Request, wantPresent bool) {
if s.ckpt == nil {
writeErr(w, http.StatusNotImplemented, "checkpoints disabled")
return
}
id, err := tce.ParseID(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "bad object id")
return
}
root, proof, err := s.store.ProofFor([32]byte(id), wantPresent)
if err != nil {
status := http.StatusUnprocessableEntity
if !errors.Is(err, errProofPresentRequested) && !errors.Is(err, errProofAbsentRequested) {
status = http.StatusInternalServerError
}
writeErr(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"proof": base64.StdEncoding.EncodeToString(proof),
"root": hex.EncodeToString(root[:]),
})
}

View file

@ -1,327 +0,0 @@
package server_test
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
)
type ckptResponse struct {
Bytes string `json:"bytes"`
Signature string `json:"signature"`
ID string `json:"id"`
PublicKey string `json:"public_key"`
Checkpoint struct {
Epoch uint64 `json:"epoch"`
Size uint64 `json:"size"`
Root string `json:"root"`
Prev string `json:"prev"`
CreatedAt uint64 `json:"created_at"`
} `json:"checkpoint"`
}
func newCkptServer(t *testing.T, dir string) *httptest.Server {
t.Helper()
srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}),
)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}
func fetchLatest(t *testing.T, url string) (*ckptResponse, int) {
t.Helper()
resp, err := http.Get(url + "/v1/checkpoint/latest")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var out ckptResponse
_ = json.Unmarshal(raw, &out)
return &out, resp.StatusCode
}
// waitForHead polls until a checkpoint with at least minSize objects exists.
func waitForHead(t *testing.T, url string, minSize uint64) *ckptResponse {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
out, code := fetchLatest(t, url)
if code == http.StatusOK && out.Checkpoint.Size >= minSize {
return out
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("no checkpoint appeared in time")
return nil
}
func TestCheckpointSignedAndVerifiable(t *testing.T) {
dir := t.TempDir()
ts := newCkptServer(t, dir)
b, sig := makeClaim(t)
resp := putWithPoW(t, ts.URL, b, sig, "", 0) // PoW disabled here
if resp.StatusCode != http.StatusOK {
t.Fatalf("put status %d", resp.StatusCode)
}
resp.Body.Close()
head := waitForHead(t, ts.URL, 1)
raw, err := base64.StdEncoding.DecodeString(head.Bytes)
if err != nil {
t.Fatal(err)
}
cp, err := checkpoint.Decode(raw)
if err != nil {
t.Fatal(err)
}
if cp.Epoch != head.Checkpoint.Epoch || cp.Size != head.Checkpoint.Size {
t.Fatal("view disagrees with canonical bytes")
}
if cp.Prev != ([32]byte{}) {
t.Fatal("genesis prev must be zeros")
}
sigBytes, err := base64.StdEncoding.DecodeString(head.Signature)
if err != nil {
t.Fatal(err)
}
pub, err := hex.DecodeString(head.PublicKey)
if err != nil || len(pub) != 32 {
t.Fatalf("bad public key: %v", err)
}
if err := checkpoint.Verify(pub, raw, sigBytes); err != nil {
t.Fatal("signature failed to verify:", err)
}
idSum := sha256.Sum256(raw)
if hex.EncodeToString(idSum[:]) != head.ID {
t.Fatal("id is not SHA-256 of canonical bytes")
}
}
func TestCheckpointChainLinks(t *testing.T) {
dir := t.TempDir()
ts := newCkptServer(t, dir)
// EveryN=1: every stored object produces exactly one signed head.
for i := 0; i < 4; i++ {
b, sig := makeClaim(t)
r := putWithPoW(t, ts.URL, b, sig, "", 0)
r.Body.Close()
}
waitForHead(t, ts.URL, 4)
fetchEpoch := func(ep string) *ckptResponse {
t.Helper()
resp, err := http.Get(ts.URL + "/v1/checkpoint/" + ep)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("epoch %s status %d", ep, resp.StatusCode)
}
var out ckptResponse
json.NewDecoder(resp.Body).Decode(&out)
return &out
}
h1 := fetchEpoch("1")
h2 := fetchEpoch("2")
h3 := fetchEpoch("3")
if h1.Checkpoint.Epoch != 1 || h1.Checkpoint.Size != 1 {
t.Fatalf("genesis head wrong: %+v", h1.Checkpoint)
}
if h1.Checkpoint.Prev != hexZero() {
t.Fatal("genesis prev not zeros")
}
// Each head links to the previous by content hash.
for i, cur := range []*ckptResponse{h2, h3} {
prev := []*ckptResponse{h1, h2}[i]
rawPrev, _ := base64.StdEncoding.DecodeString(prev.Bytes)
want := sha256.Sum256(rawPrev)
if cur.Checkpoint.Prev != hex.EncodeToString(want[:]) {
t.Fatalf("head %d does not link to %d", i+2, i+1)
}
}
latest, code := fetchLatest(t, ts.URL)
if code != http.StatusOK || latest.Checkpoint.Epoch != 4 {
t.Fatalf("latest = epoch %d (status %d)", latest.Checkpoint.Epoch, code)
}
if latest.Checkpoint.Size != 4 {
t.Fatalf("latest size %d", latest.Checkpoint.Size)
}
}
func hexZero() string { return hex.EncodeToString(make([]byte, 32)) }
func TestProofEndpointsVerifyAgainstSmt(t *testing.T) {
ts := newCkptServer(t, t.TempDir())
b, sig := makeClaim(t)
r := putWithPoW(t, ts.URL, b, sig, "", 0)
r.Body.Close()
objID := tceComputeID(b)
waitForHead(t, ts.URL, 1)
// Inclusion proof for the stored object.
resp, err := http.Get(ts.URL + "/v1/proof/object/" + objID)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("inclusion proof status %d", resp.StatusCode)
}
var out struct {
Proof string `json:"proof"`
Root string `json:"root"`
}
json.NewDecoder(resp.Body).Decode(&out)
proof, _ := base64.StdEncoding.DecodeString(out.Proof)
root, _ := hex.DecodeString(out.Root)
var rootArr [32]byte
copy(rootArr[:], root)
if !smt.VerifyInclusion(rootArr, [32]byte(mustID(t, objID)), proof) {
t.Fatal("inclusion proof failed verification")
}
// Absence proof for an unknown object.
var unknown [32]byte
unknown[0] = 0xAB
resp2, err := http.Get(ts.URL + "/v1/proof/absent/" + hex.EncodeToString(unknown[:]))
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Fatalf("absence proof status %d", resp2.StatusCode)
}
var out2 struct {
Proof string `json:"proof"`
Root string `json:"root"`
}
json.NewDecoder(resp2.Body).Decode(&out2)
proof2, _ := base64.StdEncoding.DecodeString(out2.Proof)
root2, _ := hex.DecodeString(out2.Root)
copy(rootArr[:], root2)
if !smt.VerifyAbsence(rootArr, unknown, proof2) {
t.Fatal("absence proof failed verification")
}
// Wrong kind: absence of a present object must be refused.
resp3, err := http.Get(ts.URL + "/v1/proof/absent/" + objID)
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("absence-of-present status %d", resp3.StatusCode)
}
}
func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) {
dir := t.TempDir()
srv1 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ts1 := httptest.NewServer(srv1.Handler())
for i := 0; i < 2; i++ {
b, sig := makeClaim(t)
r := putWithPoW(t, ts1.URL, b, sig, "", 0)
r.Body.Close()
}
before := waitForHead(t, ts1.URL, 2)
ts1.Close()
// The seed file must persist so the relay identity survives restarts.
if _, err := os.Stat(filepath.Join(dir, "relay_key.seed")); err != nil {
t.Fatal("relay key not persisted:", err)
}
srv2 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ts2 := httptest.NewServer(srv2.Handler())
defer ts2.Close()
after, code := fetchLatest(t, ts2.URL)
if code != http.StatusOK {
t.Fatalf("no history after restart (status %d)", code)
}
if after.ID != before.ID {
t.Fatal("checkpoint history lost across restart")
}
afterRaw, _ := base64.StdEncoding.DecodeString(after.Bytes)
cpAfter, err := checkpoint.Decode(afterRaw)
if err != nil {
t.Fatal(err)
}
// Root is a function of the set: replaying the directory reproduces it.
b, sig := makeClaim(t)
r := putWithPoW(t, ts2.URL, b, sig, "", 0)
r.Body.Close()
_ = cpAfter
next := waitForHead(t, ts2.URL, 3)
if next.Checkpoint.Epoch != cpAfter.Epoch+1 {
t.Fatalf("restart reset epoch: %d -> %d", cpAfter.Epoch, next.Checkpoint.Epoch)
}
if next.Checkpoint.Prev != after.ID {
t.Fatal("restarted chain does not link to persisted head")
}
}
func TestConfigExposesRelayPubkey(t *testing.T) {
ts := newCkptServer(t, t.TempDir())
resp, err := http.Get(ts.URL + "/v1/config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var cfg map[string]string
json.NewDecoder(resp.Body).Decode(&cfg)
if len(cfg["relay_pubkey"]) != 64 {
t.Fatalf("config missing relay_pubkey: %v", cfg)
}
if _, err := hex.DecodeString(cfg["relay_pubkey"]); err != nil {
t.Fatal("relay_pubkey not hex:", err)
}
if !bytes.Contains([]byte(cfg["audience"]), []byte("trust")) {
t.Fatal("audience lost")
}
}
func mustID(t *testing.T, hexStr string) []byte {
t.Helper()
b, err := hex.DecodeString(hexStr)
if err != nil {
t.Fatal(err)
}
return b
}
func tceComputeID(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}

View file

@ -1,105 +0,0 @@
package server
// Gossip: relays and light nodes exchange signed heads so that a split view
// (one mirror quietly serving a different object set) becomes detectable by
// comparing what independent peers claim for the same relay key. Reception
// is passive pull-free v1: peers POST heads here; the active polling side is
// the light node (internal/lightnode).
//
// Trust rules are deliberately narrow:
// - A head is stored only if its signature verifies under the public key
// that claims to have produced it, and its ID matches its bytes.
// - Keys are pinned on first contact (TOFU) per peer key, not per IP: the
// unit of trust is the relay identity, not the network path.
// - Two heads from one key at one epoch with different IDs mean the
// operator of that key is equivocating; the conflict is recorded and
// exposed rather than silently resolved.
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"net/http"
"sync"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
)
type peerHead struct {
Head signedHead `json:"head"`
PublicKey string `json:"public_key"`
}
type gossipEntry struct {
head peerHead
epoch uint64
id [32]byte
}
type gossipState struct {
mu sync.Mutex
peers map[string]*gossipEntry // relay pubkey hex -> latest observed head
}
func newGossipState() *gossipState {
return &gossipState{peers: make(map[string]*gossipEntry)}
}
func (s *Server) handleGossipCheckpoint(w http.ResponseWriter, r *http.Request) {
var in peerHead
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "bad gossip body")
return
}
pub, err := hex.DecodeString(in.PublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
writeErr(w, http.StatusBadRequest, "bad public_key")
return
}
cp, err := checkpoint.Decode(in.Head.Bytes)
if err != nil {
writeErr(w, http.StatusUnprocessableEntity, "bad checkpoint bytes")
return
}
if err := checkpoint.Verify(ed25519.PublicKey(pub), in.Head.Bytes, in.Head.Signature); err != nil {
s.metrics.inc(&s.metrics.gossipRejected)
writeErr(w, http.StatusUnauthorized, "signature fails")
return
}
idSum := checkpoint.ID(in.Head.Bytes)
var claimedID [32]byte
if got, err := hex.DecodeString(in.Head.ID); err != nil || len(got) != 32 {
writeErr(w, http.StatusBadRequest, "bad id")
return
} else {
copy(claimedID[:], got)
}
if claimedID != idSum {
writeErr(w, http.StatusUnprocessableEntity, "id does not match bytes")
return
}
keyHex := in.PublicKey
s.gossip.mu.Lock()
prev, seen := s.gossip.peers[keyHex]
switch {
case !seen || cp.Epoch > prev.epoch:
s.gossip.peers[keyHex] = &gossipEntry{head: in, epoch: cp.Epoch, id: idSum}
case cp.Epoch == prev.epoch && idSum != prev.id:
// Same key, same epoch, different bytes: equivocation.
s.metrics.inc(&s.metrics.gossipDivergence)
}
s.gossip.mu.Unlock()
s.metrics.inc(&s.metrics.gossipAccepted)
writeJSON(w, http.StatusOK, map[string]string{"status": "stored"})
}
func (s *Server) handlePeerHeads(w http.ResponseWriter, r *http.Request) {
s.gossip.mu.Lock()
defer s.gossip.mu.Unlock()
out := make([]peerHead, 0, len(s.gossip.peers))
for _, e := range s.gossip.peers {
out = append(out, e.head)
}
writeJSON(w, http.StatusOK, map[string]any{"heads": out})
}

View file

@ -1,140 +0,0 @@
package server_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
"git.n1ko.dev/Niko/niko_trust/internal/server"
)
func signHead(t *testing.T, priv ed25519.PrivateKey, cp *checkpoint.Checkpoint) map[string]any {
t.Helper()
b, err := cp.Encode()
if err != nil {
t.Fatal(err)
}
sig, err := checkpoint.Sign(priv, b)
if err != nil {
t.Fatal(err)
}
idSum := checkpoint.ID(b)
pubHex := hex.EncodeToString(priv.Public().(ed25519.PublicKey))
return map[string]any{
"head": map[string]any{
"bytes": base64.StdEncoding.EncodeToString(b),
"signature": base64.StdEncoding.EncodeToString(sig),
"id": hex.EncodeToString(idSum[:]),
},
"public_key": pubHex,
}
}
func postGossip(t *testing.T, url string, body map[string]any) *http.Response {
t.Helper()
buf := &bytes.Buffer{}
json.NewEncoder(buf).Encode(body)
resp, err := http.Post(url+"/v1/gossip/checkpoint", "application/json", buf)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestGossipAcceptsValidHead(t *testing.T) {
srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithCheckpoints("", server.CheckpointConfig{Interval: time.Hour}))
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
body := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 1, Size: 7, CreatedAt: 1_700_000_000})
resp := postGossip(t, ts.URL, body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d", resp.StatusCode)
}
list, err := http.Get(ts.URL + "/v1/peers/heads")
if err != nil {
t.Fatal(err)
}
defer list.Body.Close()
var out struct {
Heads []struct {
PublicKey string `json:"public_key"`
} `json:"heads"`
}
json.NewDecoder(list.Body).Decode(&out)
if len(out.Heads) != 1 || out.Heads[0].PublicKey != hex.EncodeToString(pub) {
t.Fatalf("peer heads wrong: %+v", out.Heads)
}
}
func TestGossipRejectsBadSignatureAndMismatchedID(t *testing.T) {
srv := server.New(server.Config{Audience: "trust.n1ko.dev"})
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
_ = pub
// Corrupt the signature.
body := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 2, CreatedAt: 1_700_000_000})
head := body["head"].(map[string]any)
sig, _ := base64.StdEncoding.DecodeString(head["signature"].(string))
sig[0] ^= 0xFF
head["signature"] = base64.StdEncoding.EncodeToString(sig)
if r := postGossip(t, ts.URL, body); r.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad signature status %d", r.StatusCode)
}
// Claim a different id than the bytes hash to.
body2 := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 3, CreatedAt: 1_700_000_060})
head2 := body2["head"].(map[string]any)
head2["id"] = hex.EncodeToString(make([]byte, 32))
if r := postGossip(t, ts.URL, body2); r.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("mismatched id status %d", r.StatusCode)
}
}
func TestGossipDetectsEquivocation(t *testing.T) {
srv := server.New(server.Config{Audience: "trust.n1ko.dev"})
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
_, priv, _ := ed25519.GenerateKey(rand.Reader)
// Two heads, same key, same epoch, different roots.
a := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 5, Size: 1, CreatedAt: 1_700_000_000})
aRoot := checkpoint.Checkpoint{Epoch: 5, Size: 1, Root: [32]byte{1}, CreatedAt: 1_700_000_000}
b := signHead(t, priv, &aRoot)
if r := postGossip(t, ts.URL, a); r.StatusCode != http.StatusOK {
t.Fatal("first head rejected")
}
r2 := postGossip(t, ts.URL, b)
defer r2.Body.Close()
if r2.StatusCode != http.StatusOK {
t.Fatalf("conflicting head not stored: %d", r2.StatusCode)
}
metrics, err := http.Get(ts.URL + "/v1/metrics")
if err != nil {
t.Fatal(err)
}
defer metrics.Body.Close()
buf := make([]byte, 4096)
n, _ := metrics.Body.Read(buf)
if !strings.Contains(string(buf[:n]), "trust_gossip_divergence 1") {
t.Fatalf("divergence not recorded:\n%s", buf[:n])
}
}

View file

@ -20,13 +20,6 @@ type Metrics struct {
assertionsFailed atomic.Uint64
requestsTotal atomic.Uint64
sessionsActive atomic.Int64
powOK atomic.Uint64
powFailed atomic.Uint64
checkpointsSigned atomic.Uint64
trieSize atomic.Uint64
gossipAccepted atomic.Uint64
gossipRejected atomic.Uint64
gossipDivergence atomic.Uint64
wsConnections atomic.Int64
wsMessagesSent atomic.Uint64
@ -41,13 +34,17 @@ func NewMetrics() *Metrics {
m := &Metrics{objectsByType: make(map[string]*atomic.Uint64)}
for _, name := range []string{
"identity", "claim", "revocation", "approval_request",
"approval_response", "auth_assertion",
"approval_response", "auth_assertion", "delegation",
"key_rotation_request", "key_rotation_confirm",
} {
m.objectsByType[name] = &atomic.Uint64{}
}
return m
}
// inc records an event.
func (m *Metrics) inc(f *atomic.Uint64) { f.Add(1) }
// incStoredType records one accepted object of the given type.
func (m *Metrics) incStoredType(typ string) {
if c, ok := m.objectsByType[typ]; ok {
@ -55,9 +52,6 @@ func (m *Metrics) incStoredType(typ string) {
}
}
// inc records an event.
func (m *Metrics) inc(f *atomic.Uint64) { f.Add(1) }
// Write renders the counters in Prometheus text format.
func (m *Metrics) Write(w io.Writer) {
fmt.Fprintf(w, "# TYPE trust_objects_stored counter\n")
@ -74,20 +68,6 @@ func (m *Metrics) Write(w io.Writer) {
fmt.Fprintf(w, "trust_requests_total %d\n", m.requestsTotal.Load())
fmt.Fprintf(w, "# TYPE trust_sessions_active gauge\n")
fmt.Fprintf(w, "trust_sessions_active %d\n", m.sessionsActive.Load())
fmt.Fprintf(w, "# TYPE trust_pow_ok counter\n")
fmt.Fprintf(w, "trust_pow_ok %d\n", m.powOK.Load())
fmt.Fprintf(w, "# TYPE trust_pow_failed counter\n")
fmt.Fprintf(w, "trust_pow_failed %d\n", m.powFailed.Load())
fmt.Fprintf(w, "# TYPE trust_checkpoints_signed counter\n")
fmt.Fprintf(w, "trust_checkpoints_signed %d\n", m.checkpointsSigned.Load())
fmt.Fprintf(w, "# TYPE trust_trie_size gauge\n")
fmt.Fprintf(w, "trust_trie_size %d\n", m.trieSize.Load())
fmt.Fprintf(w, "# TYPE trust_gossip_accepted counter\n")
fmt.Fprintf(w, "trust_gossip_accepted %d\n", m.gossipAccepted.Load())
fmt.Fprintf(w, "# TYPE trust_gossip_rejected counter\n")
fmt.Fprintf(w, "trust_gossip_rejected %d\n", m.gossipRejected.Load())
fmt.Fprintf(w, "# TYPE trust_gossip_divergence counter\n")
fmt.Fprintf(w, "trust_gossip_divergence %d\n", m.gossipDivergence.Load())
fmt.Fprintf(w, "# TYPE trust_ws_connections gauge\n")
fmt.Fprintf(w, "trust_ws_connections %d\n", m.wsConnections.Load())
fmt.Fprintf(w, "# TYPE trust_ws_messages_sent counter\n")

View file

@ -1,171 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
)
// powTTL is how long an issued PoW challenge stays valid.
const powTTL = 5 * time.Minute
// powChallengeLimitPerMin caps challenge issuance per IP per minute.
const powChallengeLimitPerMin = 30
// powPurpose names what a challenge will be spent on. The purpose selects the
// difficulty the client must solve for, so it is fixed at issuance.
type powPurpose string
const (
powPurposePut powPurpose = "put"
powPurposeAuth powPurpose = "auth"
)
// powChallengeRecord is one issued, unspent challenge.
type powChallengeRecord struct {
key pow.Key
bits int
purpose powPurpose
expiry time.Time
}
// Option configures the relay at construction time.
type Option func(*Server)
// WithPow enables proof-of-work admission control: putBits guards
// POST /v1/objects, authBits guards POST /v1/auth/challenge. Zero disables a
// tier; values above pow.MaxDifficulty are clamped.
func WithPow(putBits, authBits int) Option {
return func(s *Server) {
s.powPutBits = clampDifficulty(putBits)
s.powAuthBits = clampDifficulty(authBits)
}
}
func clampDifficulty(n int) int {
switch {
case n < 0:
return 0
case n > pow.MaxDifficulty:
return pow.MaxDifficulty
default:
return n
}
}
// wirePow is the JSON form of a solved challenge attached to a request body.
type wirePow struct {
Key string `json:"key"`
Counter uint32 `json:"counter"`
}
// rateLimitPowChallenge applies the per-IP cap before issuing.
func (s *Server) rateLimitPowChallenge(w http.ResponseWriter, r *http.Request) {
if !s.powChallengeLimiter.allow(clientIP(r)) {
s.metrics.inc(&s.metrics.powFailed)
writeErr(w, http.StatusTooManyRequests, "rate limited")
return
}
s.handlePowChallenge(w, r)
}
// handlePowChallenge issues a fresh single-use challenge key.
//
// The request may name a purpose ("put", default, or "auth"); the response
// carries the exact difficulty to solve for that purpose. Challenges are
// bound at spend time to the submission they accompany.
func (s *Server) handlePowChallenge(w http.ResponseWriter, r *http.Request) {
if s.powPutBits <= 0 && s.powAuthBits <= 0 {
writeErr(w, http.StatusBadRequest, "proof of work disabled")
return
}
var body struct {
Purpose string `json:"purpose"`
}
if r.Body != nil {
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body)
}
purpose := powPurpose(body.Purpose)
if purpose == "" {
purpose = powPurposePut
}
bits := s.powPutBits
if purpose == powPurposeAuth {
bits = s.powAuthBits
}
if purpose != powPurposePut && purpose != powPurposeAuth || bits <= 0 {
s.metrics.inc(&s.metrics.powFailed)
writeErr(w, http.StatusBadRequest, "unknown purpose or tier disabled")
return
}
key := pow.NewKey()
s.mu.Lock()
s.prunePowLocked()
s.powChallenges[key.String()] = powChallengeRecord{
key: key,
bits: bits,
purpose: purpose,
expiry: now().Add(powTTL),
}
s.mu.Unlock()
s.metrics.inc(&s.metrics.challengesIssued)
writeJSON(w, http.StatusOK, map[string]any{
"key": key.String(),
"difficulty": bits,
"ttl": int(powTTL / time.Second),
})
}
// prunePowLocked drops expired challenges. The caller holds s.mu.
func (s *Server) prunePowLocked() {
ts := now()
for k, rec := range s.powChallenges {
if rec.expiry.Before(ts) {
delete(s.powChallenges, k)
}
}
}
// admitPoW enforces one tier of admission control. target binds the proof to
// this exact submission. It reports whether the request may proceed and, on
// refusal, the client-facing reason.
func (s *Server) admitPoW(proof *wirePow, purpose powPurpose, target [pow.TargetSize]byte, wantBits int) (bool, string) {
if wantBits <= 0 {
return true, ""
}
if proof == nil {
s.metrics.inc(&s.metrics.powFailed)
return false, "proof of work required"
}
key, err := pow.ParseProof(proof.Key, proof.Counter)
if err != nil {
s.metrics.inc(&s.metrics.powFailed)
return false, "malformed proof of work"
}
hexKey := key.KeyHex
s.mu.Lock()
rec, ok := s.powChallenges[hexKey]
if ok {
delete(s.powChallenges, hexKey) // single use, even on failure paths below
}
s.mu.Unlock()
if !ok || rec.purpose != purpose || now().After(rec.expiry) {
s.metrics.inc(&s.metrics.powFailed)
return false, "unknown or expired challenge"
}
if !pow.Verify(rec.key, target, rec.bits, proof.Counter) {
s.metrics.inc(&s.metrics.powFailed)
return false, "invalid proof of work"
}
s.metrics.inc(&s.metrics.powOK)
return true, ""
}
// zeroTarget is the authentication binding target: an auth challenge proves
// work against nothing but the server-chosen key itself.
func zeroTarget() [pow.TargetSize]byte {
return [pow.TargetSize]byte{}
}

View file

@ -1,295 +0,0 @@
package server_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// newPowServer builds a relay with both PoW tiers at a difficulty cheap
// enough to solve inline in tests.
func newPowServer(t *testing.T) *httptest.Server {
t.Helper()
srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithPow(8, 8))
return httptest.NewServer(srv.Handler())
}
func getPoWChallenge(t *testing.T, baseURL, purpose string) (keyHex string, difficulty int) {
t.Helper()
body := ""
if purpose != "" {
body = `{"purpose":"` + purpose + `"}`
}
resp, err := http.Post(baseURL+"/v1/pow/challenge", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("challenge status %d: %s", resp.StatusCode, raw)
}
var out struct {
Key string `json:"key"`
Difficulty int `json:"difficulty"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out.Key, out.Difficulty
}
func putWithPoW(t *testing.T, baseURL string, tceBytes, sig []byte, keyHex string, counter uint32) *http.Response {
t.Helper()
body := map[string]any{"tce": tceBytes, "signature": sig}
if keyHex != "" {
body["pow"] = map[string]any{"key": keyHex, "counter": counter}
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
t.Fatal(err)
}
resp, err := http.Post(baseURL+"/v1/objects", "application/json", buf)
if err != nil {
t.Fatal(err)
}
return resp
}
func makeClaim(t *testing.T) (tceBytes, sig []byte) {
t.Helper()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"pow.test": tce.Bool(true)},
CreatedAt: uint64(time.Now().Unix()),
Serial: 1,
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return b, issuer.Sign(b)
}
func TestPutRequiresProofOfWork(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
b, sig := makeClaim(t)
resp := putWithPoW(t, ts.URL, b, sig, "", 0)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status %d, want 429", resp.StatusCode)
}
var out struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if !strings.Contains(out.Error, "proof of work required") {
t.Fatalf("unexpected error %q", out.Error)
}
}
func TestPutWithValidProofSucceedsAndChallengeIsSingleUse(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
if diff != 8 {
t.Fatalf("difficulty %d, want 8", diff)
}
b, sig := makeClaim(t)
target := [32]byte(tce.ComputeID(b))
k, err := pow.ParseKey(keyHex)
if err != nil {
t.Fatal(err)
}
counter, ok := pow.Solve(k, target, diff)
if !ok {
t.Fatal("solve failed")
}
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter)
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, raw)
}
resp.Body.Close()
// The same challenge must not open a second submission.
b2, sig2 := makeClaim(t)
resp2 := putWithPoW(t, ts.URL, b2, sig2, keyHex, counter)
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("replayed challenge status %d, want 429", resp2.StatusCode)
}
}
func TestPutWithWrongCounterFails(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
b, sig := makeClaim(t)
target := [32]byte(tce.ComputeID(b))
// At low difficulty the successor of the minimal solution occasionally
// also meets the target (~2^-8); retry challenges until the wrong
// counter is provably wrong, keeping the test deterministic.
for attempt := 0; attempt < 8; attempt++ {
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
k, err := pow.ParseKey(keyHex)
if err != nil {
t.Fatal(err)
}
counter, ok := pow.Solve(k, target, diff)
if !ok {
t.Fatal("solve failed")
}
if pow.Verify(k, target, diff, counter+1) {
continue // unlucky key: successor is also valid, try another
}
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter+1)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status %d, want 429", resp.StatusCode)
}
return
}
t.Skip("no challenge yielded an invalid successor within budget")
}
func TestChallengePurposeIsEnforced(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
// An auth-purpose challenge must not unlock object storage.
keyHex, _ := getPoWChallenge(t, ts.URL, "auth")
b, sig := makeClaim(t)
resp := putWithPoW(t, ts.URL, b, sig, keyHex, 7)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("cross-purpose challenge accepted with status %d", resp.StatusCode)
}
}
func TestAuthChallengeRequiresProofOfWork(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
// Without a proof the auth challenge is refused.
resp, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status %d, want 429", resp.StatusCode)
}
// With a valid proof it succeeds and returns an auth challenge.
keyHex, diff := getPoWChallenge(t, ts.URL, "auth")
k, _ := pow.ParseKey(keyHex)
var zero [32]byte
counter, ok := pow.Solve(k, zero, diff)
if !ok {
t.Fatal("solve failed")
}
buf := &bytes.Buffer{}
json.NewEncoder(buf).Encode(map[string]any{"pow": map[string]any{"key": keyHex, "counter": counter}})
resp2, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", buf)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp2.Body)
t.Fatalf("status %d: %s", resp2.StatusCode, raw)
}
var out struct {
Challenge string `json:"challenge"`
}
if err := json.NewDecoder(resp2.Body).Decode(&out); err != nil || out.Challenge == "" {
t.Fatalf("no challenge returned (err=%v)", err)
}
}
func TestUnknownChallengeRejected(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
fake := pow.NewKey()
b, sig := makeClaim(t)
resp := putWithPoW(t, ts.URL, b, sig, fake.String(), 3)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status %d, want 429", resp.StatusCode)
}
}
func TestPoWChallengeRateLimitedPerIP(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
for i := 0; i < 30; i++ {
resp, err := http.Post(ts.URL+"/v1/pow/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("request %d: status %d, want 200", i, resp.StatusCode)
}
}
resp, err := http.Post(ts.URL+"/v1/pow/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status %d, want 429 on request 31", resp.StatusCode)
}
}
func TestPoWMetricsExposed(t *testing.T) {
ts := newPowServer(t)
defer ts.Close()
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
k, _ := pow.ParseKey(keyHex)
b, sig := makeClaim(t)
counter, _ := pow.Solve(k, [32]byte(tce.ComputeID(b)), diff)
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter)
resp.Body.Close()
mresp, err := http.Get(ts.URL + "/v1/metrics")
if err != nil {
t.Fatal(err)
}
defer mresp.Body.Close()
raw, _ := io.ReadAll(mresp.Body)
for _, want := range []string{"trust_pow_ok 1", "trust_pow_failed"} {
if !strings.Contains(string(raw), want) {
t.Errorf("metrics missing %q:\n%s", want, raw)
}
}
fmt.Fprint(io.Discard, raw)
}

View file

@ -1,7 +1,6 @@
package server
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
@ -14,7 +13,6 @@ import (
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
@ -35,29 +33,13 @@ type Server struct {
putLimiter *ipLimiter
challengeLimiter *ipLimiter
powChallengeLimiter *ipLimiter
powPutBits int
powAuthBits int
mu sync.Mutex
challenges map[string]time.Time // challenge hex -> expiry
powChallenges map[string]powChallengeRecord // pow key hex -> issued challenge
sessions map[string]session // session token -> session
// ckpt publishes the signed object-set commitment; nil when disabled.
ckpt *checkpointState
// gossip records heads other relays have announced, for split-view
// detection. Present even when checkpoints are disabled locally: a node
// can relay others' claims about their own logs.
gossip *gossipState
// ws streams newly stored envelopes to subscribed sessions.
ws *wsHubState
// bft is the optional finality validator state; nil unless enabled.
bft *bftState
}
type session struct {
@ -67,9 +49,8 @@ type session struct {
}
// New builds a relay from the given Config. Any zero-valued field falls back to
// DefaultConfig, so callers may pass a partially-populated Config. Network-layer
// features (PoW, checkpoints, BFT) are layered on via Options.
func New(cfg Config, opts ...Option) *Server {
// DefaultConfig, so callers may pass a partially-populated Config.
func New(cfg Config) *Server {
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":8080"
}
@ -111,16 +92,10 @@ func New(cfg Config, opts ...Option) *Server {
challengeTTL: cfg.ChallengeTTL,
sessionTTL: cfg.SessionTTL,
challenges: make(map[string]time.Time),
powChallenges: make(map[string]powChallengeRecord),
sessions: make(map[string]session),
gossip: newGossipState(),
ws: newWSHub(),
putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow),
challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow),
powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute),
}
for _, opt := range opts {
opt(s)
}
s.ready = true
return s
@ -145,18 +120,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/readyz", s.handleReady)
mux.HandleFunc("POST /v1/auth/challenge", s.rateLimitChallenge)
mux.HandleFunc("POST /v1/auth/assert", s.handleAssert)
mux.HandleFunc("POST /v1/pow/challenge", s.rateLimitPowChallenge)
mux.HandleFunc("GET /v1/checkpoint/latest", s.handleCheckpointLatest)
mux.HandleFunc("GET /v1/checkpoint/{epoch}", s.handleCheckpointEpoch)
mux.HandleFunc("GET /v1/proof/object/{id}", s.handleProofObject)
mux.HandleFunc("GET /v1/proof/absent/{id}", s.handleProofAbsent)
mux.HandleFunc("POST /v1/gossip/checkpoint", s.handleGossipCheckpoint)
mux.HandleFunc("GET /v1/peers/heads", s.handlePeerHeads)
mux.HandleFunc("GET /v1/ws", s.handleWS)
mux.HandleFunc("POST /v1/bft/proposal", s.handleBFTProposal)
mux.HandleFunc("POST /v1/bft/vote", s.handleBFTVote)
mux.HandleFunc("GET /v1/bft/state", s.handleBFTState)
mux.HandleFunc("GET /v1/bft/certificate/{height}", s.handleBFTCertificate)
return mux
}
@ -206,15 +170,6 @@ func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "missing tce")
return
}
// Admission control binds the proof to this exact submission by hashing
// over the content ID of the enclosed bytes.
id := tce.ComputeID(req.TCE)
var target [pow.TargetSize]byte
copy(target[:], id.Bytes())
if ok, msg := s.admitPoW(req.Pow, powPurposePut, target, s.powPutBits); !ok {
writeErr(w, http.StatusTooManyRequests, msg)
return
}
objectID, created, err := s.store.Put(req.TCE, req.Signature, req.ObjectID)
if err != nil {
s.metrics.inc(&s.metrics.objectsRejected)
@ -225,12 +180,9 @@ func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
if created {
if typ, obj, derr := transport.DecodeObject(req.TCE); derr == nil {
s.metrics.incStoredType(typ)
if created {
s.notifyCheckpoint()
s.hubBroadcastObj(typ, obj, req.TCE, req.Signature, objectID)
}
}
}
writeJSON(w, http.StatusOK, map[string]string{"object_id": objectID})
}
@ -425,29 +377,9 @@ func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
out := map[string]string{"audience": s.audience}
if s.ckpt != nil {
out["relay_pubkey"] = s.ckpt.pubHex
}
writeJSON(w, http.StatusOK, out)
writeJSON(w, http.StatusOK, map[string]string{"audience": s.audience})
}
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
// Admission control for challenge issuance itself: an anonymous flood of
// auth attempts must pay per attempt, not merely per IP per minute.
// The body is optional when no PoW tier is configured.
var body wireRequest
raw := s.readCapped(w, r)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &body); err != nil {
writeErr(w, http.StatusBadRequest, "bad envelope")
return
}
}
if ok, msg := s.admitPoW(body.Pow, powPurposeAuth, zeroTarget(), s.powAuthBits); !ok {
writeErr(w, http.StatusTooManyRequests, msg)
return
}
ch := make([]byte, tce.ChallengeSize)
if _, err := rand.Read(ch); err != nil {
writeErr(w, http.StatusInternalServerError, "challenge")
@ -568,7 +500,6 @@ type wireRequest struct {
TCE []byte `json:"tce"`
Signature []byte `json:"signature"`
ObjectID string `json:"object_id,omitempty"`
Pow *wirePow `json:"pow,omitempty"`
}
// decodeWire reads a JSON request body from the request, enforcing the

View file

@ -24,7 +24,6 @@ import (
"time"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
@ -57,11 +56,6 @@ type Store struct {
// letting anyone pick a winner (docs/ROTATION.md).
confirmedRotations map[string]struct{}
// trie commits to the set of stored object IDs. It is grow-only and its
// root is a function of the set alone, so restarts and insertion order
// cannot change it (docs/CHECKPOINT.md).
trie *smt.Trie
// maxPerSubject bounds how many claims a single subject may have in the
// store, to keep the in-memory indexes from growing without bound under a
// hostile or buggy publisher.
@ -83,7 +77,6 @@ func NewStore(dir string, maxPerSubject int) *Store {
answeredRequests: make(map[string]struct{}),
confirmedRotations: make(map[string]struct{}),
trie: smt.New(),
maxPerSubject: maxPerSubject,
}
if dir != "" {
@ -144,7 +137,6 @@ func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, bool, erro
// addLocked inserts an already-validated object into the in-memory indexes. The
// caller must hold s.mu.
func (s *Store) addLocked(tceBytes, sig []byte, id string, obj any) {
s.trieInsertLocked(id)
s.byID[id] = &transport.Envelope{
TCE: append([]byte(nil), tceBytes...),
Signature: append([]byte(nil), sig...),
@ -340,50 +332,7 @@ func addKey(m map[string]map[string]struct{}, key, id string) {
set[id] = struct{}{}
}
// TrieRoot returns the Merkle commitment over the stored object ID set.
func (s *Store) TrieRoot() [32]byte {
s.mu.RLock()
defer s.mu.RUnlock()
return s.trie.Root()
}
// TrieSize returns the number of distinct objects committed by TrieRoot.
func (s *Store) TrieSize() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return uint64(s.trie.Len())
}
// ProofFor returns the trie root and an inclusion proof (wantPresent) or
// absence proof for the given object ID. Requesting the wrong kind for what
// the store actually holds is a caller error, reported via sentinel errors.
func (s *Store) ProofFor(id [32]byte, wantPresent bool) ([32]byte, []byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
root := s.trie.Root()
if wantPresent {
proof, err := s.trie.InclusionProof(id)
if err != nil {
return root, nil, errProofAbsentRequested
}
return root, proof, nil
}
proof, err := s.trie.AbsenceProof(id)
if err != nil {
return root, nil, errProofPresentRequested
}
return root, proof, nil
}
// trieInsertLocked adds one object ID to the commitment. The caller holds
// s.mu for writing and has already established the ID is new.
func (s *Store) trieInsertLocked(id string) {
k, err := tce.ParseID(id)
if err != nil {
return // unreachable: ids are always computed content addresses
}
s.trie.Insert([32]byte(k))
}
// now is overridable in tests.
var now = time.Now

View file

@ -1,85 +0,0 @@
package smt_test
import (
"math/rand"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
)
// FuzzVerifyProofsIsTotal asserts the proof verifiers are total functions of
// their inputs: any byte string either verifies or does not, and neither
// outcome may panic. Valid proofs for random sets are seeded so the fuzzer
// starts from well-formed structures and mutates around them.
func FuzzVerifyProofsIsTotal(f *testing.F) {
r := rand.New(rand.NewSource(3))
keys := make([][32]byte, 30)
for i := range keys {
r.Read(keys[i][:])
}
tr := smt.New()
for _, k := range keys {
tr.Insert(k)
}
root := tr.Root()
var incSeed []byte
if p, err := tr.InclusionProof(keys[0]); err == nil {
incSeed = p
f.Add(append([]byte(nil), p...))
}
if p, err := tr.AbsenceProof(keys[0]); err == nil {
f.Add(append([]byte(nil), p...))
}
var absent [32]byte
absent = keys[1]
absent[0] ^= 0x80
if !tr.Contains(absent) {
if p, err := tr.AbsenceProof(absent); err == nil {
f.Add(append([]byte(nil), p...))
}
}
f.Add([]byte{0x01, 0x00})
f.Add([]byte{0x02})
f.Add([]byte{0x03, 0xff})
f.Add([]byte{})
var probe [32]byte
probe = keys[2]
f.Fuzz(func(t *testing.T, proof []byte) {
smt.VerifyInclusion(root, probe, proof)
smt.VerifyAbsence(root, probe, proof)
smt.VerifyInclusion(smt.EmptyRoot, probe, proof)
_ = incSeed
})
}
// FuzzInsertNeverCorrupts drives Insert with arbitrary keys and asserts the
// core invariants after every mutation: membership accuracy and root
// stability under replay.
func FuzzInsertNeverCorrupts(f *testing.F) {
f.Add([]byte{1}, []byte{2})
f.Add(make([]byte, 32), make([]byte, 32))
f.Fuzz(func(t *testing.T, a, b []byte) {
var ka, kb [32]byte
copy(ka[:], a)
copy(kb[:], b)
t1 := smt.New()
t1.Insert(ka)
t1.Insert(kb)
root1 := t1.Root()
t2 := smt.New()
t2.Insert(kb)
t2.Insert(ka)
if t1.Len() == t2.Len() && root1 != t2.Root() {
t.Fatalf("order dependence: %x vs %x", root1, t2.Root())
}
if !t1.Contains(ka) || !t1.Contains(kb) {
t.Fatal("inserted key lost")
}
})
}

View file

@ -1,385 +0,0 @@
package smt
// Proof encodings. All multi-byte integers are uvarints; bit strings are
// packed MSB-first; hashes are 32 raw bytes.
//
// 0x01 || nSteps || step… inclusion
// 0x02 || nSteps || step… || witnessKey absence, leaf witness
// 0x03 || nSteps || step… || m || prefix || left || right absence, branch witness
// 0x04 absence in an empty trie
//
// step := nPrefixBits || prefixBits || siblingSide(1 byte) || siblingHash
//
// A step describes one branch on the path from the root toward the subject
// key: prefix is that branch's matched bits (committed inside its hash),
// siblingSide is the bit selecting the sibling subtree at the branch's
// split, and siblingHash is that subtree's commitment.
//
// Verification recomposes the root from the subject side upward. Because
// every step's prefix must also match the subject key's own bits at the
// tracked depth, a proof constructed for one key verifies for no other.
import (
"encoding/binary"
"errors"
)
type proofStep struct {
prefix []byte
prefixLen int
sibSide byte
sibling [32]byte
}
const (
proofInclusion = 0x01
proofAbsentLeaf = 0x02
proofAbsentBranch = 0x03
proofAbsentEmpty = 0x04
)
// ---------------------------------------------------------------- generation
// walkDescend follows key's bits through the trie. It returns the steps
// crossed (top-down) and where the walk stopped: at a leaf, or diverging
// inside a branch's prefix.
func (t *Trie) walkDescend(key *[KeySize]byte) (steps []proofStep, leaf *node, branch *node, depth int) {
n := t.root
d := 0
for n != nil && !n.leaf {
i := 0
for i < n.prefixLen && bit(key, d+i) == prefixBit(n.prefix, i) {
i++
}
if i < n.prefixLen {
return steps, nil, n, d
}
sd := d + n.prefixLen
b := bit(key, sd)
child := n.left
sib := n.right
sibSide := byte(1)
if b == 1 {
child = n.right
sib = n.left
sibSide = 0
}
steps = append(steps, proofStep{
prefix: append([]byte(nil), n.prefix...),
prefixLen: n.prefixLen,
sibSide: sibSide,
sibling: t.hash(sib),
})
n = child
d = sd + 1
}
if n != nil {
return steps, n, nil, d
}
return steps, nil, nil, d // empty trie
}
// InclusionProof returns a proof that key is in the trie.
func (t *Trie) InclusionProof(key [KeySize]byte) ([]byte, error) {
steps, leaf, _, _ := t.walkDescend(&key)
if t.root == nil || leaf == nil || leaf.key != key {
return nil, ErrAbsent
}
out := []byte{proofInclusion}
out = appendUvarint(out, len(steps))
out = appendSteps(out, steps)
return out, nil
}
// AbsenceProof returns a proof that key is not in the trie.
func (t *Trie) AbsenceProof(key [KeySize]byte) ([]byte, error) {
if t.root == nil {
return []byte{proofAbsentEmpty}, nil
}
steps, leaf, branch, _ := t.walkDescend(&key)
switch {
case leaf != nil && leaf.key == key:
return nil, ErrPresent
case leaf != nil:
out := []byte{proofAbsentLeaf}
out = appendUvarint(out, len(steps))
out = appendSteps(out, steps)
out = append(out, leaf.key[:]...)
return out, nil
default:
// Diverged inside branch.prefix at some bit; the branch itself is
// the witness and stays intact in the tree.
out := []byte{proofAbsentBranch}
out = appendUvarint(out, len(steps))
out = appendSteps(out, steps)
out = appendUvarint(out, branch.prefixLen)
out = append(out, branch.prefix...)
l := t.hash(branch.left)
r := t.hash(branch.right)
out = append(out, l[:]...)
out = append(out, r[:]...)
return out, nil
}
}
func appendSteps(out []byte, steps []proofStep) []byte {
for _, st := range steps {
out = appendUvarint(out, st.prefixLen)
out = append(out, st.prefix...)
out = append(out, st.sibSide)
out = append(out, st.sibling[:]...)
}
return out
}
// ----------------------------------------------------------------- verifying
type parsedStep struct {
prefix []byte
prefixLen int
sibSide byte
sibling [32]byte
split int // absolute bit index of the branch's split decision
}
type proofParser struct {
b []byte
pos int
}
func (p *proofParser) byteVal() (byte, error) {
if p.pos >= len(p.b) {
return 0, ErrBadProof
}
v := p.b[p.pos]
p.pos++
return v, nil
}
func (p *proofParser) uvarint() (int, error) {
v, n := binary.Uvarint(p.b[p.pos:])
// Every uvarint in a proof is either a step count or a bit length; both
// are bounded by one key's worth of bits.
if n <= 0 || v > KeySize*8 {
return 0, ErrBadProof
}
p.pos += n
return int(v), nil
}
func (p *proofParser) bytes(n int) ([]byte, error) {
if n < 0 || p.pos+n > len(p.b) {
return nil, ErrBadProof
}
out := p.b[p.pos : p.pos+n]
p.pos += n
return out, nil
}
var errTrailing = errors.New("smt: trailing bytes after proof")
func (p *proofParser) done() error {
if p.pos != len(p.b) {
return errTrailing
}
return nil
}
// parseSteps reads n steps, validating each prefix against key at the
// running depth, and returns them with their split positions.
func parseSteps(p *proofParser, n int, key *[KeySize]byte) ([]parsedStep, error) {
d := 0
steps := make([]parsedStep, 0, n)
for i := 0; i < n; i++ {
plen, err := p.uvarint()
if err != nil {
return nil, err
}
var nb int
if plen > 0 {
nb = (plen + 7) / 8
}
pref, err := p.bytes(nb)
if err != nil {
return nil, err
}
side, err := p.byteVal()
if err != nil || side > 1 {
return nil, ErrBadProof
}
var sib [32]byte
sb, err := p.bytes(32)
if err != nil {
return nil, err
}
copy(sib[:], sb)
// The prefix must be exactly the key's bits here: the walk that
// produced this step followed the key, so any other claim is a
// malformed proof even before hashes are checked.
for j := 0; j < plen; j++ {
if prefixBit(pref, j) != bit(key, d+j) {
return nil, ErrBadProof
}
}
steps = append(steps, parsedStep{
prefix: append([]byte(nil), pref...),
prefixLen: plen,
sibSide: side,
sibling: sib,
split: d + plen,
})
d = d + plen + 1
}
return steps, nil
}
// combine folds the subject hash upward through the steps, deepest first.
func combine(h *[32]byte, steps []parsedStep) {
for i := len(steps) - 1; i >= 0; i-- {
st := steps[i]
b := byte(1) - st.sibSide // the side the subject subtree occupies
var l, r [32]byte
if b == 0 {
l, r = *h, st.sibling
} else {
l, r = st.sibling, *h
}
*h = branchHash(st.prefix, st.prefixLen, &l, &r)
}
}
// VerifyInclusion reports whether proof attests key's membership in the set
// committed to by root.
func VerifyInclusion(root [32]byte, key [KeySize]byte, proof []byte) bool {
p := &proofParser{b: proof}
typ, err := p.byteVal()
if err != nil || typ != proofInclusion {
return false
}
n, err := p.uvarint()
if err != nil {
return false
}
steps, err := parseSteps(p, n, &key)
if err != nil {
return false
}
if err := p.done(); err != nil {
return false
}
h := leafHash(&key)
combine(&h, steps)
return h == root
}
// VerifyAbsence reports whether proof attests key's absence from the set
// committed to by root.
func VerifyAbsence(root [32]byte, key [KeySize]byte, proof []byte) bool {
p := &proofParser{b: proof}
typ, err := p.byteVal()
if err != nil {
return false
}
switch typ {
case proofAbsentEmpty:
return root == EmptyRoot
case proofAbsentLeaf:
n, err := p.uvarint()
if err != nil {
return false
}
steps, err := parseSteps(p, n, &key)
if err != nil {
return false
}
wk, err := p.bytes(KeySize)
if err != nil {
return false
}
if err := p.done(); err != nil {
return false
}
var witness [KeySize]byte
copy(witness[:], wk)
if witness == key {
return false // that would be proof of presence
}
h := leafHash(&witness)
combine(&h, steps)
return h == root
case proofAbsentBranch:
n, err := p.uvarint()
if err != nil {
return false
}
steps, err := parseSteps(p, n, &key)
if err != nil {
return false
}
m, err := p.uvarint()
if err != nil || m == 0 || m > KeySize*8 {
return false
}
pref, err := p.bytes((m + 7) / 8)
if err != nil {
return false
}
// The key must leave this prefix mid-way: matching fully would mean
// the walk continued deeper, differing nowhere means presence of
// nothing in particular.
j := -1
db := 0 // absolute bit index where the witness branch's prefix starts
if len(steps) > 0 {
db = steps[len(steps)-1].split + 1
}
for i := 0; i < m; i++ {
if prefixBit(pref, i) != bit(&key, db+i) {
j = i
break
}
}
if j < 0 {
return false
}
var ll, lr [32]byte
lb, err := p.bytes(32)
if err != nil {
return false
}
copy(ll[:], lb)
rb, err := p.bytes(32)
if err != nil {
return false
}
copy(lr[:], rb)
if err := p.done(); err != nil {
return false
}
hb := branchHash(pref, m, &ll, &lr)
// The branch hangs off the last step's split; fold it in place of a
// leaf hash, then continue through remaining ancestor steps.
if len(steps) == 0 {
return hb == root
}
top := steps[len(steps)-1]
b := byte(1) - top.sibSide
var l, r [32]byte
if b == 0 {
l, r = hb, top.sibling
} else {
l, r = top.sibling, hb
}
h := branchHash(top.prefix, top.prefixLen, &l, &r)
rest := steps[:len(steps)-1]
combine(&h, rest)
return h == root
}
return false
}

View file

@ -1,351 +0,0 @@
package smt_test
import (
"bytes"
"math/rand"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
)
func keyFromSeed(r *rand.Rand) [32]byte {
var k [32]byte
r.Read(k[:])
return k
}
func buildTrie(keys [][32]byte) (*smt.Trie, [32]byte) {
t := smt.New()
for _, k := range keys {
t.Insert(k)
}
return t, t.Root()
}
// The root must be a function of the set alone: three tries fed the same
// keys in different orders commit to the identical hash.
func TestRootIsOrderIndependent(t *testing.T) {
r := rand.New(rand.NewSource(42))
base := make([][32]byte, 300)
for i := range base {
base[i] = keyFromSeed(r)
}
shuffled := func(src [][32]byte, seed int64) [][32]byte {
out := append([][32]byte(nil), src...)
rnd := rand.New(rand.NewSource(seed))
rnd.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
return out
}
a := smt.New()
b := smt.New()
c := smt.New()
for _, k := range base {
a.Insert(k)
}
for _, k := range shuffled(base, 7) {
b.Insert(k)
}
for _, k := range shuffled(base, 99) {
c.Insert(k)
}
if a.Root() != b.Root() || b.Root() != c.Root() {
t.Fatal("roots differ across insertion orders")
}
if a.Len() != len(base) {
t.Fatalf("len %d, want %d", a.Len(), len(base))
}
}
func TestInsertIdempotent(t *testing.T) {
t1, root1 := buildTrie([][32]byte{{1}, {2}})
t2, _ := buildTrie([][32]byte{{1}, {2}, {1}, {2}})
if t1.Len() != 2 || t2.Len() != 2 {
t.Fatalf("duplicate inserts counted: %d %d", t1.Len(), t2.Len())
}
if t2.Root() != root1 {
t.Fatal("re-inserting changed the root")
}
}
func TestContains(t *testing.T) {
r := rand.New(rand.NewSource(1))
var keys [][32]byte
seen := map[[32]byte]bool{}
for len(keys) < 50 {
k := keyFromSeed(r)
if !seen[k] {
seen[k] = true
keys = append(keys, k)
}
}
tr, _ := buildTrie(keys)
for _, k := range keys {
if !tr.Contains(k) {
t.Fatal("inserted key not contained")
}
}
for i := 0; i < 200; i++ {
k := keyFromSeed(r)
if seen[k] {
continue
}
if tr.Contains(k) {
t.Fatal("absent key reported contained")
}
}
}
func TestInclusionAndAbsenceRoundTrip(t *testing.T) {
r := rand.New(rand.NewSource(5))
var keys [][32]byte
seen := map[[32]byte]bool{}
for len(keys) < 100 {
k := keyFromSeed(r)
if !seen[k] {
seen[k] = true
keys = append(keys, k)
}
}
tr, root := buildTrie(keys)
for _, k := range keys {
p, err := tr.InclusionProof(k)
if err != nil {
t.Fatalf("inclusion proof: %v", err)
}
if !smt.VerifyInclusion(root, k, p) {
t.Fatal("valid inclusion proof failed verification")
}
if _, err := tr.AbsenceProof(k); err == nil {
t.Fatal("absence proof generated for present key")
} else if err != smt.ErrPresent {
t.Fatalf("wrong error: %v", err)
}
}
checked := 0
for i := 0; checked < 100 && i < 10000; i++ {
k := keyFromSeed(r)
if seen[k] {
continue
}
checked++
p, err := tr.AbsenceProof(k)
if err != nil {
t.Fatalf("absence proof: %v", err)
}
if !smt.VerifyAbsence(root, k, p) {
t.Fatal("valid absence proof failed verification")
}
if _, err := tr.InclusionProof(k); err == nil {
t.Fatal("inclusion proof generated for absent key")
} else if err != smt.ErrAbsent {
t.Fatalf("wrong error: %v", err)
}
}
}
func TestEmptyAndSingleKeyTries(t *testing.T) {
empty := smt.New()
if empty.Root() != smt.EmptyRoot {
t.Fatal("empty trie root mismatch")
}
ap, err := empty.AbsenceProof([32]byte{9})
if err != nil {
t.Fatal(err)
}
if !smt.VerifyAbsence(smt.EmptyRoot, [32]byte{9}, ap) {
t.Fatal("empty-trie absence proof failed")
}
if smt.VerifyAbsence(smt.EmptyRoot, [32]byte{9}, []byte{0x04}) == false && false {
t.Fatal("unreachable")
}
one, root := buildTreeOfOne([32]byte{7})
ip, err := one.InclusionProof([32]byte{7})
if err != nil {
t.Fatal(err)
}
if !smt.VerifyInclusion(root, [32]byte{7}, ip) {
t.Fatal("single-key inclusion failed")
}
ap2, err := one.AbsenceProof([32]byte{8})
if err != nil {
t.Fatal(err)
}
if !smt.VerifyAbsence(root, [32]byte{8}, ap2) {
t.Fatal("single-key absence failed")
}
// The absence proof names its witness; presenting the witness itself as
// the queried key must fail.
if smt.VerifyAbsence(root, [32]byte{7}, ap2) {
t.Fatal("absence proof verified for its own witness (a present key)")
}
if smt.VerifyInclusion(root, [32]byte{8}, ip) {
t.Fatal("inclusion proof for another key verified")
}
// Soundness in the strong direction: an absence proof minted before a
// key existed must not verify once that key has been inserted.
before, err := tr100().AbsenceProof(absentKey())
if err != nil {
t.Fatal(err)
}
tr := tr100()
r := rand.New(rand.NewSource(77))
var x [32]byte
for {
r.Read(x[:])
if !tr.Contains(x) {
break
}
}
pre, err := tr.AbsenceProof(x)
if err != nil {
t.Fatal(err)
}
rootBefore := tr.Root()
tr.Insert(x)
if tr.Root() == rootBefore {
t.Fatal("insert did not change root")
}
if !tr.Contains(x) {
t.Fatal("insert lost")
}
if smt.VerifyAbsence(tr.Root(), x, pre) {
t.Fatal("stale absence proof verified after the key was inserted")
}
if !smt.VerifyAbsence(rootBefore, x, pre) {
t.Fatal("fresh absence proof failed against its own root")
}
_ = before
}
// tr100 returns a fresh trie of 100 random keys and registers the canonical
// "guaranteed absent" probe used by the soundness checks above.
func tr100() *smt.Trie {
r := rand.New(rand.NewSource(123))
tr := smt.New()
for i := 0; i < 100; i++ {
var k [32]byte
r.Read(k[:])
tr.Insert(k)
}
return tr
}
func absentKey() [32]byte {
r := rand.New(rand.NewSource(321))
for {
var k [32]byte
r.Read(k[:])
return k
}
}
func buildTreeOfOne(k [32]byte) (*smt.Trie, [32]byte) {
tr := smt.New()
tr.Insert(k)
return tr, tr.Root()
}
// Sequential and near-identical keys force deep splits and mid-prefix
// divergence, the paths naive implementations get wrong.
func TestPathologicalKeySets(t *testing.T) {
sets := [][][32]byte{
seqKeys(0), // 0x000000...
seqKeys(255), // 0xffffff...
nearKeys(), // all identical except the last bit
}
for si, set := range sets {
tr := smt.New()
for _, k := range set {
tr.Insert(k)
}
root := tr.Root()
for _, k := range set {
p, err := tr.InclusionProof(k)
if err != nil {
t.Fatalf("set %d inclusion: %v", si, err)
}
if !smt.VerifyInclusion(root, k, p) {
t.Fatalf("set %d inclusion verify failed", si)
}
}
absent := set[0]
absent[31] ^= 0x01
if tr.Contains(absent) {
continue // collision with an existing member; skip
}
p, err := tr.AbsenceProof(absent)
if err != nil {
t.Fatalf("set %d absence: %v", si, err)
}
if !smt.VerifyAbsence(root, absent, p) {
t.Fatalf("set %d absence verify failed", si)
}
}
}
func seqKeys(first byte) [][32]byte {
out := make([][32]byte, 16)
for i := range out {
out[i] = [32]byte{}
out[i][0] = first
out[i][31] = byte(i)
}
return out
}
func nearKeys() [][32]byte {
out := make([][32]byte, 4)
for i := range out {
for j := range out[i] {
out[i][j] = 0xAA
}
out[i][31] = byte(i & 1)
}
return out
}
func TestTamperedProofsRejected(t *testing.T) {
r := rand.New(rand.NewSource(11))
keys := make([][32]byte, 40)
for i := range keys {
keys[i] = keyFromSeed(r)
}
tr, root := buildTrie(keys)
inc, err := tr.InclusionProof(keys[3])
if err != nil {
t.Fatal(err)
}
absK := keys[3]
absK[0] ^= 0x80
for tr.Contains(absK) {
absK = keyFromSeed(r)
}
abs, err := tr.AbsenceProof(absK)
if err != nil {
t.Fatal(err)
}
for i := 0; i < len(inc); i++ {
bad := append([]byte(nil), inc...)
bad[i] ^= 0x01
if smt.VerifyInclusion(root, keys[3], bad) {
t.Fatalf("tampered inclusion proof (byte %d) verified", i)
}
}
for i := 0; i < len(abs); i++ {
bad := append([]byte(nil), abs...)
bad[i] ^= 0x01
if smt.VerifyAbsence(root, absK, bad) {
t.Fatalf("tampered absence proof (byte %d) verified", i)
}
}
if !bytes.Equal(inc, inc) {
t.Fatal("unreachable")
}
}

View file

@ -1,271 +0,0 @@
// Package smt implements a compressed sparse Merkle trie over fixed-size
// keys, committed to by a single root hash.
//
// Purpose. Two relays that store the same set of objects must compute the
// same root regardless of insertion order: the root is a function of the
// set, so comparing one 32-byte string detects divergence between mirrors
// instantly (docs/CHECKPOINT.md). The trie is grow-only — objects are never
// removed from the log even when revoked — which keeps every operation
// monotone and the proofs simple.
//
// Hash domain (SHA-256 throughout):
//
// leaf = H(0x00 || key)
// branch = H(0x01 || uvarint(prefixLen) || prefixBits || left || right)
// empty = H(0x02)
//
// The prefix is inside the branch hash so the trie's shape is committed to:
// without it, proofs could not be checked for canonicality. A branch exists
// only where both children exist and splits exactly at its keys' first
// differing bit, which makes the shape a function of the set alone.
//
// Proofs. An inclusion proof walks from the key's leaf to the root through
// sibling hashes. An absence proof witnesses the exact spot where the key
// would have lived: either a neighbouring leaf whose path agrees with the
// queried key up to that leaf's depth (variant A), or the branch whose
// prefix the queried key leaves mid-way (variant B). Both recombine with
// the queried key's own bits, so a proof for one key verifies for no other.
package smt
import (
"crypto/sha256"
"encoding/binary"
"errors"
)
// KeySize is the exact key size: an object content ID.
const KeySize = 32
// EmptyRoot is the root of a trie containing nothing.
var EmptyRoot = sha256.Sum256([]byte{0x02})
// Errors reported by proof generation and verification.
var (
ErrPresent = errors.New("smt: key is present")
ErrAbsent = errors.New("smt: key is absent")
ErrBadProof = errors.New("smt: bad proof")
)
type node struct {
leaf bool
// Leaf fields.
key [KeySize]byte
// Branch fields: prefix holds the bits between the parent's split and
// this node's own split, packed MSB-first, prefixLen valid bits. A
// branch always has both children.
prefix []byte
prefixLen int
left *node
right *node
}
// Trie is a grow-only set commitment. Not safe for concurrent use; callers
// serialise access.
type Trie struct {
root *node
n int
}
// New returns an empty trie.
func New() *Trie { return &Trie{} }
// Len returns the number of distinct keys inserted.
func (t *Trie) Len() int { return t.n }
// ---------------------------------------------------------------- primitives
func bit(k *[KeySize]byte, i int) byte {
return k[i>>3] >> (7 - uint(i&7)) & 1
}
func prefixBit(p []byte, i int) byte {
return p[i>>3] >> (7 - uint(i&7)) & 1
}
func commonPrefixLen(a, b *[KeySize]byte) int {
for i := 0; i < KeySize*8; i++ {
if bit(a, i) != bit(b, i) {
return i
}
}
return KeySize * 8
}
func packBits(k *[KeySize]byte, from, n int) []byte {
out := make([]byte, (n+7)/8)
for i := 0; i < n; i++ {
if bit(k, from+i) == 1 {
out[i>>3] |= 0x80 >> uint(i&7)
}
}
return out
}
func appendUvarint(b []byte, v int) []byte {
var tmp [10]byte
n := binary.PutUvarint(tmp[:], uint64(v))
return append(b, tmp[:n]...)
}
func leafHash(key *[KeySize]byte) [32]byte {
h := sha256.New()
h.Write([]byte{0x00})
h.Write(key[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
func branchHash(prefix []byte, prefixLen int, left, right *[32]byte) [32]byte {
h := sha256.New()
h.Write([]byte{0x01})
var tmp [10]byte
n := binary.PutUvarint(tmp[:], uint64(prefixLen))
h.Write(tmp[:n])
h.Write(prefix)
h.Write(left[:])
h.Write(right[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
func (t *Trie) hash(n *node) [32]byte {
if n.leaf {
return leafHash(&n.key)
}
l := t.hash(n.left)
r := t.hash(n.right)
return branchHash(n.prefix, n.prefixLen, &l, &r)
}
// Root returns the set commitment.
func (t *Trie) Root() [32]byte {
if t.root == nil {
return EmptyRoot
}
return t.hash(t.root)
}
// ------------------------------------------------------------------- insert
// Insert adds key, reporting whether it was new.
func (t *Trie) Insert(key [KeySize]byte) bool {
root, added := insertNode(t.root, &key, 0)
t.root = root
if added {
t.n++
}
return added
}
// Contains reports whether key has been inserted.
func (t *Trie) Contains(key [KeySize]byte) bool {
n := t.root
d := 0
for n != nil && !n.leaf {
i := 0
for i < n.prefixLen && bit(&key, d+i) == prefixBit(n.prefix, i) {
i++
}
if i < n.prefixLen {
return false // diverged inside the prefix
}
d += n.prefixLen
if bit(&key, d) == 0 {
n = n.left
} else {
n = n.right
}
d++
}
return n != nil && n.key == key
}
// insertNode inserts key into the subtree rooted at n, whose split decision
// begins at depth d. It returns the possibly-replaced subtree.
func insertNode(n *node, key *[KeySize]byte, d int) (*node, bool) {
if n == nil {
return &node{leaf: true, key: *key}, true
}
if n.leaf {
if n.key == *key {
return n, false
}
k := commonPrefixLen(&n.key, key)
// Shared bits d..k-1 become the new branch's prefix; bit k splits.
newBranch := &node{
prefix: packBits(key, d, k-d),
prefixLen: k - d,
}
if bit(key, k) == 0 {
newBranch.left = &node{leaf: true, key: *key}
newBranch.right = n
} else {
newBranch.left = n
newBranch.right = &node{leaf: true, key: *key}
}
return newBranch, true
}
// Branch: follow the prefix while it matches.
i := 0
for i < n.prefixLen && bit(key, d+i) == prefixBit(n.prefix, i) {
i++
}
if i < n.prefixLen {
// The key diverges inside the prefix at absolute bit d+i. Split the
// branch there: the upper half becomes the new branch's prefix, the
// old branch keeps the remainder as its own prefix.
j := d + i
upperPrefix := make([]byte, (i+7)/8)
for b := 0; b < i; b++ {
if prefixBit(n.prefix, b) == 1 {
upperPrefix[b>>3] |= 0x80 >> uint(b&7)
}
}
upper := &node{
prefix: upperPrefix,
prefixLen: i,
}
tail := copyTailBits(n.prefix, i, n.prefixLen)
lower := &node{
prefix: tail,
prefixLen: n.prefixLen - i - 1,
left: n.left,
right: n.right,
}
leaf := &node{leaf: true, key: *key}
if bit(key, j) == 0 {
upper.left = leaf
upper.right = lower
} else {
upper.left = lower
upper.right = leaf
}
return upper, true
}
sd := d + n.prefixLen
var added bool
if bit(key, sd) == 0 {
n.left, added = insertNode(n.left, key, sd+1)
} else {
n.right, added = insertNode(n.right, key, sd+1)
}
return n, added
}
// copyTailBits extracts bits [from+1, end) of p, MSB-packed.
func copyTailBits(p []byte, from, end int) []byte {
n := end - from - 1
out := make([]byte, (n+7)/8)
for i := 0; i < n; i++ {
if prefixBit(p, from+1+i) == 1 {
out[i>>3] |= 0x80 >> uint(i&7)
}
}
return out
}

View file

@ -1,2 +0,0 @@
go test fuzz v1
[]byte("\x03\x00\x0e00")

View file

@ -1,91 +0,0 @@
{
"domain": "trust.n1ko.dev/pow/1",
"hash_spec": "BLAKE3_keyed(key, domain || target || counter_be); valid iff leading_zero_bits(sum) >= difficulty",
"max_difficulty": 30,
"vectors": [
{
"name": "put/key-seed01-target-seed02/diff1",
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"difficulty": 1,
"counter": 0,
"sum_hex": "63369ce6bfc277ea69b9df45011189776b00463ce4fec6d449fff42bf0908113",
"leading_zero_bits": 1
},
{
"name": "put/key-seed01-target-seed02/diff4",
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"difficulty": 4,
"counter": 1,
"sum_hex": "046831eba83ef2b46bbcf8a7a14f3831a55b5555d3be29d26f1fd2fd5767d891",
"leading_zero_bits": 5
},
{
"name": "put/key-seed01-target-seed02/diff8",
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"difficulty": 8,
"counter": 277,
"sum_hex": "00fa52a4129d6db6729c75e22f8c72250bc268ac89061e1c1418862e62b91a24",
"leading_zero_bits": 8
},
{
"name": "auth/zero-target/diff8",
"key_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"target_hex": "0000000000000000000000000000000000000000000000000000000000000000",
"difficulty": 8,
"counter": 343,
"sum_hex": "00f8503f1687fabdf8d0a73d12d5081b748cc87d2d9959154ff3d6cf69a5eb43",
"leading_zero_bits": 8
},
{
"name": "put/key-seed02-target-seed03/diff12",
"key_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"target_hex": "0303030303030303030303030303030303030303030303030303030303030303",
"difficulty": 12,
"counter": 11745,
"sum_hex": "0009f60682638831f9fafee143bdccdbdf845b32f5658bf5d6d2a8e2341c440f",
"leading_zero_bits": 12
},
{
"name": "put/key-seed04-target-seed05/diff16",
"key_hex": "0404040404040404040404040404040404040404040404040404040404040404",
"target_hex": "0505050505050505050505050505050505050505050505050505050505050505",
"difficulty": 16,
"counter": 88240,
"sum_hex": "000008fed1164db13471bd3bc0721e83c5f643e3bb72c5dfad90c09e89d4c9ef",
"leading_zero_bits": 20
}
],
"rejects": [
{
"name": "counter-misses-threshold",
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"difficulty": 8,
"counter": 278,
"reason": "leading_zero_bits < difficulty"
},
{
"name": "wrong-key",
"key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"difficulty": 8,
"counter": 277,
"reason": "hash under a different key does not meet the target"
}
],
"config_rejects": [
{
"name": "difficulty-above-max",
"difficulty": 31,
"reason": "configuration above MaxDifficulty is clamped/rejected"
},
{
"name": "negative-difficulty",
"difficulty": -1,
"reason": "negative configuration is treated as disabled, never as a pass-all proof tier"
}
]
}

View file

@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""Reference implementation of the relay's proof-of-work admission control.
Companion to docs/POW.md and the Go package internal/pow, with the same two
purposes as tce_reference.py: generate the frozen vectors in
testdata/vectors/pow_vectors.json, and be an independent implementation so
that agreement means "two readings of the specification coincide".
Scheme:
hash = BLAKE3_keyed(key, DOMAIN || target || counter_be)
valid when `hash` has at least `difficulty` leading zero bits.
Every message here is at most len(DOMAIN)+32+4 = 57 bytes, so the reference
implements exactly the BLAKE3 rule set for single-block inputs: key words as
the chaining value, one compression call over one padded 64-byte block, with
CHUNK_START | CHUNK_END | KEYED_HASH flags. That is complete for this input
class, not an approximation.
Run: python3 tools/reference/pow_reference.py --emit testdata/vectors
"""
from __future__ import annotations
import argparse
import json
import struct
from pathlib import Path
DOMAIN = b"trust.n1ko.dev/pow/1"
KEY_SIZE = 32
TARGET_SIZE = 32
MAX_DIFFICULTY = 30
# BLAKE3 flags for a keyed hash whose entire input is one block: the chunk
# starts and ends here, the whole input is the root node, and the hash is
# keyed.
FLAG_CHUNK_START = 1 << 0
FLAG_CHUNK_END = 1 << 1
FLAG_ROOT = 1 << 3
FLAG_KEYED_HASH = 1 << 4
SINGLE_BLOCK_FLAGS = FLAG_CHUNK_START | FLAG_CHUNK_END | FLAG_ROOT | FLAG_KEYED_HASH
IV = (
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
)
MSG_PERMUTATION = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8)
def _rotr(x: int, n: int) -> int:
return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF
def _g(s: list[int], a: int, b: int, c: int, d: int, mx: int, my: int) -> None:
s[a] = (s[a] + s[b] + mx) & 0xFFFFFFFF
s[d] = _rotr(s[d] ^ s[a], 16)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
s[b] = _rotr(s[b] ^ s[c], 12)
s[a] = (s[a] + s[b] + my) & 0xFFFFFFFF
s[d] = _rotr(s[d] ^ s[a], 8)
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
s[b] = _rotr(s[b] ^ s[c], 7)
def _compress(cv: tuple[int, ...], block_words: list[int], block_len: int, flags: int) -> list[int]:
counter = 0 # first and only chunk
state = list(cv) + list(IV[:4]) + [
counter & 0xFFFFFFFF,
(counter >> 32) & 0xFFFFFFFF,
block_len,
flags,
]
m = list(block_words)
for r in range(7):
if r > 0:
m = [m[MSG_PERMUTATION[i]] for i in range(16)]
_g(state, 0, 4, 8, 12, m[0], m[1])
_g(state, 1, 5, 9, 13, m[2], m[3])
_g(state, 2, 6, 10, 14, m[4], m[5])
_g(state, 3, 7, 11, 15, m[6], m[7])
_g(state, 0, 5, 10, 15, m[8], m[9])
_g(state, 1, 6, 11, 12, m[10], m[11])
_g(state, 2, 7, 8, 13, m[12], m[13])
_g(state, 3, 4, 9, 14, m[14], m[15])
# Final transform: the first half becomes the new chaining value, the
# second half the block output. For a single-chunk root input the digest
# is the new chaining value: root_output_bytes streams all 16 words, and
# a 32-byte digest reads exactly the first eight.
for i in range(8):
state[i] ^= state[i + 8]
state[i + 8] ^= cv[i]
return state
def pow_hash(key: bytes, target: bytes, counter: int) -> bytes:
"""BLAKE3_keyed(key, DOMAIN || target || counter_be), 32-byte digest."""
if len(key) != KEY_SIZE:
raise ValueError("key must be 32 bytes")
if len(target) != TARGET_SIZE:
raise ValueError("target must be 32 bytes")
msg = DOMAIN + target + struct.pack(">I", counter)
if len(msg) > 64:
raise ValueError("reference covers single-block inputs only")
padded = msg + bytes(64 - len(msg))
block = list(struct.unpack("<16I", padded))
cv = struct.unpack("<8I", key)
words = _compress(cv, block, len(msg), SINGLE_BLOCK_FLAGS)
return struct.pack("<8I", *words[:8])
def leading_zero_bits(digest: bytes) -> int:
n = 0
for byte in digest:
if byte == 0:
n += 8
continue
mask = 0x80
while mask and not byte & mask:
n += 1
mask >>= 1
break
return n
def solve(key: bytes, target: bytes, difficulty: int) -> int:
counter = 0
while True:
if leading_zero_bits(pow_hash(key, target, counter)) >= difficulty:
return counter
counter += 1
# --------------------------------------------------------------------------
# Vector generation
# --------------------------------------------------------------------------
def fixture(seed: int) -> bytes:
return bytes([seed]) * KEY_SIZE
def emit(out_dir: Path) -> None:
vectors = []
rejects = []
# (key_seed, target_seed or None for zeros, difficulty)
cases = [
(1, 2, 1),
(1, 2, 4),
(1, 2, 8),
(2, 0, 8), # zero target: the authentication binding
(2, 3, 12),
(4, 5, 16),
]
for key_seed, target_seed, difficulty in cases:
key = fixture(key_seed)
target = bytes(TARGET_SIZE) if target_seed == 0 else fixture(target_seed)
counter = solve(key, target, difficulty)
name = "auth/zero-target" if target_seed == 0 else (
f"put/key-seed{key_seed:02x}-target-seed{target_seed:02x}"
)
vectors.append({
"name": f"{name}/diff{difficulty}",
"key_hex": key.hex(),
"target_hex": target.hex(),
"difficulty": difficulty,
"counter": counter,
"sum_hex": pow_hash(key, target, counter).hex(),
"leading_zero_bits": leading_zero_bits(pow_hash(key, target, counter)),
})
# Rejects derived from the first vector: off-by-one counter and wrong key.
base = cases[2]
key = fixture(base[0])
target = fixture(base[1])
good = solve(key, target, base[2])
bad_counter = good + 1
while leading_zero_bits(pow_hash(key, target, bad_counter)) >= base[2]:
bad_counter += 1
rejects.append({
"name": "counter-misses-threshold",
"key_hex": key.hex(),
"target_hex": target.hex(),
"difficulty": base[2],
"counter": bad_counter,
"reason": "leading_zero_bits < difficulty",
})
rejects.append({
"name": "wrong-key",
"key_hex": fixture(0xFF).hex(),
"target_hex": target.hex(),
"difficulty": base[2],
"counter": good,
"reason": "hash under a different key does not meet the target",
})
config_rejects = [
{
"name": "difficulty-above-max",
"difficulty": MAX_DIFFICULTY + 1,
"reason": "configuration above MaxDifficulty is clamped/rejected",
},
{
"name": "negative-difficulty",
"difficulty": -1,
"reason": "negative configuration is treated as disabled, never as a pass-all proof tier",
},
]
doc = {
"domain": DOMAIN.decode(),
"hash_spec": "BLAKE3_keyed(key, domain || target || counter_be); valid iff leading_zero_bits(sum) >= difficulty",
"max_difficulty": MAX_DIFFICULTY,
"vectors": vectors,
"rejects": rejects,
"config_rejects": config_rejects,
}
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "pow_vectors.json"
path.write_text(json.dumps(doc, indent=2) + "\n")
print(f"wrote {path} ({len(vectors)} vectors, {len(rejects)} rejects)")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--emit", default="testdata/vectors", help="output directory")
args = ap.parse_args()
emit(Path(args.emit))