From e23a3cab5924ce6eb6939021304e02d8b085b080 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Wed, 26 Aug 2026 00:38:45 +0300 Subject: [PATCH] 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. --- cmd/lightnode/main.go | 94 ---- cmd/server/main.go | 37 +- docs/API.md | 31 +- docs/BFT.md | 78 --- docs/CHECKPOINT.md | 105 ---- docs/POW.md | 100 ---- docs/SECURITY-REVIEW.md | 104 ---- docs/TRUST-MODEL.md | 14 +- go.mod | 3 - go.sum | 4 - internal/bft/bft.go | 347 ------------- internal/bft/mesh_test.go | 264 ---------- internal/bft/validator.go | 307 ------------ internal/checkpoint/checkpoint.go | 154 ------ internal/checkpoint/checkpoint_test.go | 126 ----- internal/identity/invariants_test.go | 38 +- internal/lightnode/auth.go | 212 -------- internal/lightnode/handler.go | 463 ------------------ internal/lightnode/integration_test.go | 366 -------------- internal/lightnode/lightnode.go | 262 ---------- internal/lightnode/ws.go | 358 -------------- internal/pow/pow.go | 168 ------- internal/pow/pow_test.go | 216 -------- internal/pow/vectors_test.go | 133 ----- internal/server/bft.go | 216 -------- internal/server/checkpoint.go | 397 --------------- internal/server/checkpoint_test.go | 327 ------------- internal/server/gossip.go | 105 ---- internal/server/gossip_test.go | 140 ------ internal/server/metrics.go | 50 +- internal/server/pow.go | 171 ------- internal/server/pow_test.go | 295 ----------- internal/server/server.go | 115 +---- internal/server/store.go | 51 -- internal/smt/fuzz_test.go | 85 ---- internal/smt/proofs.go | 385 --------------- internal/smt/property_test.go | 351 ------------- internal/smt/smt.go | 271 ---------- .../FuzzVerifyProofsIsTotal/d49a0e270743a8e3 | 2 - testdata/vectors/pow_vectors.json | 91 ---- tools/reference/pow_reference.py | 230 --------- 41 files changed, 53 insertions(+), 7213 deletions(-) delete mode 100644 cmd/lightnode/main.go delete mode 100644 docs/BFT.md delete mode 100644 docs/CHECKPOINT.md delete mode 100644 docs/POW.md delete mode 100644 docs/SECURITY-REVIEW.md delete mode 100644 internal/bft/bft.go delete mode 100644 internal/bft/mesh_test.go delete mode 100644 internal/bft/validator.go delete mode 100644 internal/checkpoint/checkpoint.go delete mode 100644 internal/checkpoint/checkpoint_test.go delete mode 100644 internal/lightnode/auth.go delete mode 100644 internal/lightnode/handler.go delete mode 100644 internal/lightnode/integration_test.go delete mode 100644 internal/lightnode/lightnode.go delete mode 100644 internal/lightnode/ws.go delete mode 100644 internal/pow/pow.go delete mode 100644 internal/pow/pow_test.go delete mode 100644 internal/pow/vectors_test.go delete mode 100644 internal/server/bft.go delete mode 100644 internal/server/checkpoint.go delete mode 100644 internal/server/checkpoint_test.go delete mode 100644 internal/server/gossip.go delete mode 100644 internal/server/gossip_test.go delete mode 100644 internal/server/pow.go delete mode 100644 internal/server/pow_test.go delete mode 100644 internal/smt/fuzz_test.go delete mode 100644 internal/smt/proofs.go delete mode 100644 internal/smt/property_test.go delete mode 100644 internal/smt/smt.go delete mode 100644 internal/smt/testdata/fuzz/FuzzVerifyProofsIsTotal/d49a0e270743a8e3 delete mode 100644 testdata/vectors/pow_vectors.json delete mode 100644 tools/reference/pow_reference.py diff --git a/cmd/lightnode/main.go b/cmd/lightnode/main.go deleted file mode 100644 index 0328b44..0000000 --- a/cmd/lightnode/main.go +++ /dev/null @@ -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 -} diff --git a/cmd/server/main.go b/cmd/server/main.go index a48f2cd..153211d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/docs/API.md b/docs/API.md index 1d6abc4..5ae81ce 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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": "" } -POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" } +POST /v1/auth/challenge -> { "challenge": "" } +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 ` or `?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": "", "signature": "", - "pow": { "key": "", "counter": } } +POST { "tce": "", "signature": "" } 200 { "object_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_` counter per object type. ## Status code summary diff --git a/docs/BFT.md b/docs/BFT.md deleted file mode 100644 index 75e9608..0000000 --- a/docs/BFT.md +++ /dev/null @@ -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 = ⌊(n−1)/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. diff --git a/docs/CHECKPOINT.md b/docs/CHECKPOINT.md deleted file mode 100644 index 69e42da..0000000 --- a/docs/CHECKPOINT.md +++ /dev/null @@ -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 `/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. diff --git a/docs/POW.md b/docs/POW.md deleted file mode 100644 index 7b31258..0000000 --- a/docs/POW.md +++ /dev/null @@ -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": "", "counter": }`. - -### 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. diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md deleted file mode 100644 index 422def5..0000000 --- a/docs/SECURITY-REVIEW.md +++ /dev/null @@ -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. diff --git a/docs/TRUST-MODEL.md b/docs/TRUST-MODEL.md index 4648fef..a2c98f1 100644 --- a/docs/TRUST-MODEL.md +++ b/docs/TRUST-MODEL.md @@ -11,7 +11,7 @@ check is a vulnerability. | Issuer | Holds a signing key; makes a signed statement (a claim, a response). | | Subject | The address a claim is *about*. | | Approver | An address trusted to allow/deny an `ApprovalRequest`. | -| Relay | The HTTP server (`internal/server`). Stores signed objects. No keys. | +| Relay | The HTTP server (`internal/server`). Stores signed objects. No keys. | | Verifier | The consumer running `internal/verify`. Checks signatures, decides trust. | ## Objects are signed by their issuer @@ -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 diff --git a/go.mod b/go.mod index 94cb4f1..8c65f9d 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9c234ad..85fe4a3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/bft/bft.go b/internal/bft/bft.go deleted file mode 100644 index 8558e79..0000000 --- a/internal/bft/bft.go +++ /dev/null @@ -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 -} diff --git a/internal/bft/mesh_test.go b/internal/bft/mesh_test.go deleted file mode 100644 index 7cea0cd..0000000 --- a/internal/bft/mesh_test.go +++ /dev/null @@ -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 -} diff --git a/internal/bft/validator.go b/internal/bft/validator.go deleted file mode 100644 index 7fe4c8f..0000000 --- a/internal/bft/validator.go +++ /dev/null @@ -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 -} diff --git a/internal/checkpoint/checkpoint.go b/internal/checkpoint/checkpoint.go deleted file mode 100644 index 0cf2280..0000000 --- a/internal/checkpoint/checkpoint.go +++ /dev/null @@ -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 -} diff --git a/internal/checkpoint/checkpoint_test.go b/internal/checkpoint/checkpoint_test.go deleted file mode 100644 index 53a3365..0000000 --- a/internal/checkpoint/checkpoint_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/identity/invariants_test.go b/internal/identity/invariants_test.go index 893874c..cc0dd20 100644 --- a/internal/identity/invariants_test.go +++ b/internal/identity/invariants_test.go @@ -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) } } } diff --git a/internal/lightnode/auth.go b/internal/lightnode/auth.go deleted file mode 100644 index 18d7d8c..0000000 --- a/internal/lightnode/auth.go +++ /dev/null @@ -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 -} diff --git a/internal/lightnode/handler.go b/internal/lightnode/handler.go deleted file mode 100644 index a8c01e2..0000000 --- a/internal/lightnode/handler.go +++ /dev/null @@ -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 -} diff --git a/internal/lightnode/integration_test.go b/internal/lightnode/integration_test.go deleted file mode 100644 index d176ceb..0000000 --- a/internal/lightnode/integration_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/lightnode/lightnode.go b/internal/lightnode/lightnode.go deleted file mode 100644 index 1e0f502..0000000 --- a/internal/lightnode/lightnode.go +++ /dev/null @@ -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 -} diff --git a/internal/lightnode/ws.go b/internal/lightnode/ws.go deleted file mode 100644 index 0a23a53..0000000 --- a/internal/lightnode/ws.go +++ /dev/null @@ -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 diff --git a/internal/pow/pow.go b/internal/pow/pow.go deleted file mode 100644 index ec6bb44..0000000 --- a/internal/pow/pow.go +++ /dev/null @@ -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 -} diff --git a/internal/pow/pow_test.go b/internal/pow/pow_test.go deleted file mode 100644 index de11265..0000000 --- a/internal/pow/pow_test.go +++ /dev/null @@ -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)) - } -} diff --git a/internal/pow/vectors_test.go b/internal/pow/vectors_test.go deleted file mode 100644 index e6e6539..0000000 --- a/internal/pow/vectors_test.go +++ /dev/null @@ -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 - } - }) - } -} diff --git a/internal/server/bft.go b/internal/server/bft.go deleted file mode 100644 index c9fd9d0..0000000 --- a/internal/server/bft.go +++ /dev/null @@ -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) -} diff --git a/internal/server/checkpoint.go b/internal/server/checkpoint.go deleted file mode 100644 index 0b42979..0000000 --- a/internal/server/checkpoint.go +++ /dev/null @@ -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[:]), - }) -} diff --git a/internal/server/checkpoint_test.go b/internal/server/checkpoint_test.go deleted file mode 100644 index 0ec9656..0000000 --- a/internal/server/checkpoint_test.go +++ /dev/null @@ -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[:]) -} diff --git a/internal/server/gossip.go b/internal/server/gossip.go deleted file mode 100644 index 3bc9c83..0000000 --- a/internal/server/gossip.go +++ /dev/null @@ -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}) -} diff --git a/internal/server/gossip_test.go b/internal/server/gossip_test.go deleted file mode 100644 index e317122..0000000 --- a/internal/server/gossip_test.go +++ /dev/null @@ -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]) - } -} diff --git a/internal/server/metrics.go b/internal/server/metrics.go index 15c5de2..40ff17e 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -12,23 +12,16 @@ import ( // Prometheus text exposition format. No external dependency: the values are // plain atomic counters rendered as text. type Metrics struct { - objectsStored atomic.Uint64 - objectsRejected atomic.Uint64 - objectsDeleted atomic.Uint64 - challengesIssued atomic.Uint64 - assertionsOK atomic.Uint64 - 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 + objectsStored atomic.Uint64 + objectsRejected atomic.Uint64 + objectsDeleted atomic.Uint64 + challengesIssued atomic.Uint64 + assertionsOK atomic.Uint64 + assertionsFailed atomic.Uint64 + requestsTotal atomic.Uint64 + sessionsActive atomic.Int64 + wsConnections atomic.Int64 + wsMessagesSent atomic.Uint64 // objectsByType holds one counter per known object type name. The map is // populated once at construction and never mutated afterwards, so @@ -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") diff --git a/internal/server/pow.go b/internal/server/pow.go deleted file mode 100644 index 8f8149c..0000000 --- a/internal/server/pow.go +++ /dev/null @@ -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{} -} diff --git a/internal/server/pow_test.go b/internal/server/pow_test.go deleted file mode 100644 index 169cf99..0000000 --- a/internal/server/pow_test.go +++ /dev/null @@ -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) -} diff --git a/internal/server/server.go b/internal/server/server.go index 85ffe88..0c8482d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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" @@ -33,31 +31,15 @@ type Server struct { challengeTTL time.Duration sessionTTL time.Duration - putLimiter *ipLimiter - challengeLimiter *ipLimiter - powChallengeLimiter *ipLimiter + putLimiter *ipLimiter + challengeLimiter *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 + mu sync.Mutex + challenges map[string]time.Time // challenge hex -> expiry + sessions map[string]session // session token -> session // 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" } @@ -104,23 +85,17 @@ func New(cfg Config, opts ...Option) *Server { cfg.SessionTTL = 30 * time.Minute } s := &Server{ - store: NewStore(cfg.DataDir, cfg.MaxPerSubject), - audience: cfg.Audience, - metrics: NewMetrics(), - maxBodyBytes: cfg.MaxBodyBytes, - 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) + store: NewStore(cfg.DataDir, cfg.MaxPerSubject), + audience: cfg.Audience, + metrics: NewMetrics(), + maxBodyBytes: cfg.MaxBodyBytes, + challengeTTL: cfg.ChallengeTTL, + sessionTTL: cfg.SessionTTL, + challenges: make(map[string]time.Time), + sessions: make(map[string]session), + ws: newWSHub(), + putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow), + challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow), } 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,10 +180,7 @@ 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) - } + 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") @@ -565,10 +497,9 @@ func identityFromPubKey(pub []byte) string { // object storage and authentication. The optional pow field carries a solved // admission challenge; it is transport metadata and never reaches TCE bytes. type wireRequest struct { - TCE []byte `json:"tce"` - Signature []byte `json:"signature"` - ObjectID string `json:"object_id,omitempty"` - Pow *wirePow `json:"pow,omitempty"` + TCE []byte `json:"tce"` + Signature []byte `json:"signature"` + ObjectID string `json:"object_id,omitempty"` } // decodeWire reads a JSON request body from the request, enforcing the diff --git a/internal/server/store.go b/internal/server/store.go index b1b0850..05d22c9 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -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 diff --git a/internal/smt/fuzz_test.go b/internal/smt/fuzz_test.go deleted file mode 100644 index 8b2d98a..0000000 --- a/internal/smt/fuzz_test.go +++ /dev/null @@ -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") - } - }) -} diff --git a/internal/smt/proofs.go b/internal/smt/proofs.go deleted file mode 100644 index 2befe4c..0000000 --- a/internal/smt/proofs.go +++ /dev/null @@ -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 -} diff --git a/internal/smt/property_test.go b/internal/smt/property_test.go deleted file mode 100644 index 7fa5351..0000000 --- a/internal/smt/property_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/smt/smt.go b/internal/smt/smt.go deleted file mode 100644 index 0687620..0000000 --- a/internal/smt/smt.go +++ /dev/null @@ -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 -} diff --git a/internal/smt/testdata/fuzz/FuzzVerifyProofsIsTotal/d49a0e270743a8e3 b/internal/smt/testdata/fuzz/FuzzVerifyProofsIsTotal/d49a0e270743a8e3 deleted file mode 100644 index d1234f3..0000000 --- a/internal/smt/testdata/fuzz/FuzzVerifyProofsIsTotal/d49a0e270743a8e3 +++ /dev/null @@ -1,2 +0,0 @@ -go test fuzz v1 -[]byte("\x03\x00\x0e00") diff --git a/testdata/vectors/pow_vectors.json b/testdata/vectors/pow_vectors.json deleted file mode 100644 index 87772c4..0000000 --- a/testdata/vectors/pow_vectors.json +++ /dev/null @@ -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" - } - ] -} diff --git a/tools/reference/pow_reference.py b/tools/reference/pow_reference.py deleted file mode 100644 index bf25181..0000000 --- a/tools/reference/pow_reference.py +++ /dev/null @@ -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))