diff --git a/.gitignore b/.gitignore index 5c7c3a6..0880c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ # Python caches __pycache__/ *.pyc + +# Local build output +/niko_trust diff --git a/cmd/lightnode/main.go b/cmd/lightnode/main.go new file mode 100644 index 0000000..0328b44 --- /dev/null +++ b/cmd/lightnode/main.go @@ -0,0 +1,94 @@ +// 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 a4e6856..ce5c817 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -22,10 +23,32 @@ func main() { addr := flag.String("addr", ":8080", "listen address") audience := flag.String("audience", "trust.n1ko.dev", "server audience bound into auth assertions") data := flag.String("data", "", "directory to persist objects (empty = in-memory)") + 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() logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) - srv := server.New(*audience, *data) + srv := server.New(*audience, *data, + server.WithPow(*powPutBits, *powAuthBits), + server.WithCheckpoints(*data, server.CheckpointConfig{Interval: *ckptInterval, EveryN: *ckptEvery}), + ) + if *bftValidators != "" && *bftURLs != "" { + srv.SetBFT(server.BFTConfig{ + ValidatorKeys: splitCSV(*bftValidators), + ValidatorURLs: splitCSV(*bftURLs), + RoundTimeout: *bftTimeout, + }) + } + + ctx, stop := context.WithCancel(context.Background()) + srv.StartCheckpoints(ctx) + srv.StartBFT(ctx) + defer stop() h := &http.Server{ Addr: *addr, Handler: srv.Handler(), @@ -40,15 +63,26 @@ func main() { } }() - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + stop() logger.Info("shutting down") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx2, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := h.Shutdown(ctx); err != nil { + if err := h.Shutdown(ctx2); err != nil { logger.Error("shutdown", "err", err) } 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 +} diff --git a/docs/API.md b/docs/API.md index 51145d4..a526fd5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,10 +9,14 @@ Read endpoints require a session token. Obtain one with the challenge/assert handshake (AuthAssertion bound to the server's `audience`): ``` -POST /v1/auth/challenge -> { "challenge": "" } -POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" } +POST /v1/pow/challenge {"purpose":"auth"} -> { "key", "difficulty", "ttl" } +POST /v1/auth/challenge ({"pow":{...}}) -> { "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`, @@ -24,17 +28,57 @@ 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` body | Store a signed object (claim/request/...) | +| POST | `/v1/objects` | none | `Envelope` + optional `pow` | 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": "..." }` | +| GET | `/v1/config` | none | — | `{ "audience": "...", "relay_pubkey": "..." }` | | 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`) | +## WebSocket streaming (`GET /v1/ws`) + +Authenticate like any read endpoint (bearer token; browsers may use +`?token=`). The token's scopes gate channels exactly as the REST list +endpoints do: `read:claims`, `read:requests`, `read:responses`, +`read:revocations`, or the generic `read`/`*`. + +After connecting, send JSON text frames: + +```json +{ "op": "subscribe", "channel": "claims", "key": "" } +{ "op": "unsubscribe", "channel": "claims", "key": "..." } +``` + +Channels mirror the list endpoints: `claims` (by subject), `requests` +(by recipient), `responses` (by request hash), `revocations` (by claim id). +Every newly stored object on a subscribed channel arrives as: + +```json +{ "event": "object", "channel": "...", "key": "...", + "object_id": "...", "envelope": { "tce": "...", "signature": "..." } } +``` + +The envelope is raw and unverified by design: verify it locally exactly as +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`) - The relay does **not** verify the signature (see `docs/TRUST-MODEL.md`); @@ -52,13 +96,17 @@ Rate limit: `POST /v1/auth/challenge` is capped at **30/min per IP** (`429`). Request / response shape: ``` -PUT { "tce": "", "signature": "" } +POST { "tce": "", "signature": "", + "pow": { "key": "", "counter": } } 200 { "object_id": "" } 422 { "error": "server: subject ... quota exceeded" } 413 { "error": "payload too large" } -429 { "error": "rate limited" } +429 { "error": "proof of work required" | "invalid proof of work" | "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) All require a session with the matching `read:*` scope and return a JSON object @@ -68,8 +116,12 @@ decode the `TCE` to read the issuer/subject/fields. - `subject` / `recipient` / `request` / `claim` is required; omitting it is `400`. -- Pagination: `limit` (max items, `0` = no cap) and `offset` (skip) query - params. Example: `?subject=trust1def&limit=10&offset=20`. +- Pagination: results are ordered lexicographically by content id. + - `limit` (max items, `0` = no cap) and `offset` (skip) query params. + Example: `?subject=trust1def&limit=10&offset=20`. + - `after=` is the stable cursor: only ids greater than it are + returned. Prefer it over `offset` — offset shifts when objects are + inserted concurrently, a cursor does not. - Missing/invalid scope is `403`; missing token is `401`. ## Health & metrics @@ -78,7 +130,8 @@ decode the `TCE` to read the issuer/subject/fields. - `GET /v1/readyz` → `200 { "status": "ready" }`, or `503` if not ready. - `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_assertions_ok`, `trust_assertions_failed`, `trust_sessions_active`, + `trust_pow_ok`, `trust_pow_failed`. ## Status code summary diff --git a/docs/BFT.md b/docs/BFT.md new file mode 100644 index 0000000..75e9608 --- /dev/null +++ b/docs/BFT.md @@ -0,0 +1,78 @@ +# 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 new file mode 100644 index 0000000..69e42da --- /dev/null +++ b/docs/CHECKPOINT.md @@ -0,0 +1,105 @@ +# 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/DELEGATION.md b/docs/DELEGATION.md new file mode 100644 index 0000000..8984859 --- /dev/null +++ b/docs/DELEGATION.md @@ -0,0 +1,66 @@ +# Delegation + +How one identity lends its voice to another, and how a verifier decides +whether a claim made by someone it does not know is backed by someone it +does. + +The wire object is `DelegationClaim` (PROTOCOL.md §8.7, tag `0x07`). This +document is about what it means and how chains are resolved. + +--- + +## 1. The statement + +``` +granter says: grantee may issue claims covering these exact predicates, + re-delegating at most max_depth further times. +``` + +- **Exact predicates only.** `{minecraft.op: true}` covers the key + `minecraft.op` and nothing else. There are no prefixes and no wildcards: + a grant of `user.` that silently covered `users.admin` would turn an + administrative convenience into an escalation primitive. +- **Every predicate value is the boolean `true`**, enforced at decode time. + Coverage is membership, not interpretation (INV-5). +- **`max_depth` counts hops below the grant.** `0` means the grantee speaks + for itself only; `2` allows the grantee to delegate onward once, and that + sub-grantee once more. +- **Revocation mirrors claims**: the granter signs a `Revocation` targeting + the delegation's object ID. Expired grants die on their own; supersession + (`serial`) replaces terms without revoking history. + +## 2. Chain resolution + +A consumer anchors trust in roots — issuers it already decided to believe +(`Policy.TrustedIssuers`). When a claim's issuer is not a root, the verifier +searches for a path: + +``` +trusted root ──grant──▶ … ──grant──▶ claim issuer ──claim──▶ subject +``` + +Rules, implemented deterministically in `verify.Graph`: + +1. Every link must be signature-valid, current at the evaluation instant, + unrevoked by its granter, and cover the claim's exact predicate. +2. Where several grants could serve, the highest `serial` wins, ties broken + by lowest content ID — map order never leaks into a decision. +3. Total hops are bounded by `Policy.MaxDepth` (default 3), and each link's + own `max_depth` must fit the hops below it: link *i* of *k* needs + `max_depth ≥ k−1−i`. +4. Cycles cannot loop: depth bounds terminate every walk. +5. The accepted result reports the full path in `Result.Chain` + (`issuer → … → root`) as audit evidence. + +An empty `TrustedIssuers` keeps the original model exactly: any issuer the +consumer fed into the graph may satisfy the policy directly. Delegation only +activates when roots are named. + +## 3. What delegation does not mean + +- It is not identity merging: the claim still names its real issuer, and the + chain names everyone who lent authority. +- It is not transitive by default: a root that grants with `max_depth: 0` + creates exactly one hop and nothing more. +- A hostile link can stop forwarding, but cannot forge: every hop is signed + by its own granter, and the root sees only grants it signed itself. diff --git a/docs/POW.md b/docs/POW.md new file mode 100644 index 0000000..7b31258 --- /dev/null +++ b/docs/POW.md @@ -0,0 +1,100 @@ +# 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/PROTOCOL.md b/docs/PROTOCOL.md index f03b018..c18d09d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -85,7 +85,10 @@ last field ends, and any trailing byte is an error (§12.3). | `0x04` | `ApprovalRequest` | sender | §8.4 | | `0x05` | `ApprovalResponse` | responder | §8.5 | | `0x06` | `AuthAssertion` | the asserting identity | §8.6 | -| `0x07`–`0x7f` | reserved for future object types | — | | +| `0x07` | `DelegationClaim` | the granter | §8.7 | +| `0x08` | `KeyRotationRequest` | the successor | §8.8 | +| `0x09` | `KeyRotationConfirm` | the predecessor | §8.9 | +| `0x0a`–`0x7f` | reserved for future object types | — | | | `0x80`–`0xff` | permanently reserved | — | | The tag appears immediately after the magic, before any field. Because it is @@ -346,6 +349,9 @@ Whole-object TCE limits: | `ApprovalRequest` | 8192 | | `ApprovalResponse` | 1024 | | `AuthAssertion` | 1024 | +| `DelegationClaim` | 2048 | +| `KeyRotationRequest` | 1024 | +| `KeyRotationConfirm` | 1024 | These are protocol limits. A server may impose stricter operational limits and reject with a resource status; that is a separate mechanism and does not make @@ -568,6 +574,71 @@ inherently local to one server. The challenge is generated by the server with a CSPRNG, is 32 bytes, is valid once, and expires. A server must never accept a challenge it did not issue. +### 8.7 DelegationClaim — tag `0x07` + +| # | Field | Encoding | Notes | +|---|---|---|---| +| 1 | `granter` | identity | signer; lends its authority | +| 2 | `grantee` | identity | may now speak under the grant | +| 3 | `predicates` | map, ≥1, ≤32 | exact claim keys covered; every value must be `true` | +| 4 | `max_depth` | uvarint | further re-delegation hops permitted below this grant | +| 5 | `created_at` | timestamp | | +| 6 | `expires_at` | timestamp or `0` | `0` = no expiry; otherwise > `created_at` | +| 7 | `serial` | uvarint | supersession, same rule as claims (§13.2) | +| 8 | `nonce` | 16 bytes | | + +Semantics: *the granter asserts that grantee may issue claims whose +predicate is one of `predicates`, re-delegating at most `max_depth` further +times.* The protocol establishes who granted what to whom; whether a +consumer accepts a chain is policy, resolved in the verifier by walking from +the claim's issuer up to a root it already trusts ([DELEGATION.md](DELEGATION.md)). + +Mandatory checks beyond the signature: + +1. Every predicate value is the boolean `true`. A delegation covers a key or + it does not; any other spelling would let two implementations disagree + about coverage while both saw a valid signature. +2. Timing follows the claim rules: `ValidateCurrent` with the §13.1 skew. + +Revocation mirrors claims: a `Revocation` signed by **the granter** targeting +this object's ID withdraws the grant (`VerifyRevocationOfDelegation`). A +revocation signed by anyone else is meaningless. + +Matching is exact. Prefix and wildcard delegation are deliberately absent: +`user.` must never silently cover `users.admin`. + +### 8.8 KeyRotationRequest — tag `0x08` + +| # | Field | Encoding | Notes | +|---|---|---|---| +| 1 | `successor` | identity | signer; the incoming key | +| 2 | `predecessor` | identity | the key being succeeded; only its consent counts | +| 3 | `created_at` | timestamp | | +| 4 | `expires_at` | timestamp | > `created_at`, at most 60 s later (§8.4 bound) | + +Semantics: *the successor claims succession from predecessor.* Alone it +proves nothing about consent — anyone can name any predecessor. + +### 8.9 KeyRotationConfirm — tag `0x09` + +| # | Field | Encoding | Notes | +|---|---|---|---| +| 1 | `rotation_hash` | 32 bytes | object ID of the exact request (INV-4) | +| 2 | `created_at` | timestamp | | +| 3 | `nonce` | 16 bytes | | + +Mandatory checks beyond the signature: + +1. The confirm verifies under the request's **predecessor** key. +2. `SHA-256(received request TCE) == rotation_hash`. +3. `request.created_at ≤ confirm.created_at ≤ request.expires_at`, with the + §13.1 skew allowance, exactly as approvals. + +There is deliberately no standalone acceptance of a confirm, just as for +approval responses: the verifier must hold both objects +(`VerifyKeyRotationConfirm`). Freshness across long horizons is policy, not +wire format (`Policy.RotationMaxAge` in [ROTATION.md](ROTATION.md)). + --- ## 9. Fields deliberately excluded from TCE @@ -780,6 +851,9 @@ The vector set covers: | `approval_response/allow` | `request_hash` binding | | `approval_response/deny` | differs from allow in one byte, yielding a different ID and signature | | `auth_assertion/ws` | challenge, scope and audience binding | +| `delegation/minimal` | one exact predicate, `max_depth` 0 | +| `delegation/multi-predicate` | three predicates exercising map order, depth 2, no expiry | +| `key_rotation/request` + `key_rotation/confirm` | succession claim and its hash-bound consent | The file also contains `number_canonicalization` (28 accepted tokens with their canonical forms, 18 rejected tokens with reasons) and `rejects` diff --git a/docs/ROTATION.md b/docs/ROTATION.md new file mode 100644 index 0000000..d7613e8 --- /dev/null +++ b/docs/ROTATION.md @@ -0,0 +1,66 @@ +# Key rotation + +How an identity changes its key without losing the trust built around the +old one — and why two objects are needed to do it honestly. + +Wire objects: `KeyRotationRequest` (tag `0x08`) and +`KeyRotationConfirm` (tag `0x09`), PROTOCOL.md §8.8–8.9. + +--- + +## 1. Why two objects + +A rotation needs evidence that **both** keys agreed: + +- The **successor** signs a request: "I succeed predecessor." Alone it is + worthless — anyone can name any predecessor, including a victim's. +- The **predecessor** signs a confirm bound to that exact request by content + ID (`rotation_hash = SHA-256(request TCE)`, the INV-4 pattern). Alone it is + unintelligible. + +Together they are cryptographic evidence of mutual consent. A stolen +predecessor key can still rotate silently — cryptography cannot detect +theft — but it can never *point* the identity at a key whose owner never +consented, and the freshness bound below caps how long a silent thief keeps +working. + +## 2. Wire rules + +- The request lives at most **60 seconds**, the same format bound as + approval requests. Rotation intent is short-lived; long-horizon freshness + is policy, not wire. +- The confirm must sit inside the request window with the usual ±120 s skew + allowance, exactly like an approval response. +- One relay stores at most one confirm per request; competing confirms make + a link unusable rather than letting a verifier pick a winner. + +## 3. Resolution + +With `Policy.TrustedIssuers` set and a claim's issuer not a root, the +verifier walks confirmed successions backwards: + +``` +trusted root (old) ◀──confirm── new ◀──confirm── newer ──claim──▶ subject +``` + +- Every link must fully verify: both signatures plus the hash binding, + checked against stored bytes via `VerifyKeyRotationConfirm`. +- Hops are bounded by `Policy.MaxDepth` (default for rotations: 4). +- **Freshness**: with `Policy.RotationMaxAge > 0`, a link's confirm must be + no older than that many seconds at the evaluation instant. Zero disables + the check — history stays valid forever, which suits long-lived roots + whose rotation ceremony was audited once. +- The accepted result reports the path in `Result.RotationChain` + (`issuer → … → root`) as audit evidence. + +Rotation composes independently of delegation: an issuer may reach a trusted +root either by being granted voice or by *being* the root's newer key. If +both paths exist, delegation wins as the more specific statement; both chains +are reported when applicable. + +## 4. What rotation does not mean + +- It does not transfer revocations: claims signed by the old key keep their + own lifecycle; only the *authority to be believed going forward* moves. +- It does not heal compromise retroactively: anything signed before the + confirm stands as signed. diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md new file mode 100644 index 0000000..422def5 --- /dev/null +++ b/docs/SECURITY-REVIEW.md @@ -0,0 +1,104 @@ +# 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 a52f3b4..4648fef 100644 --- a/docs/TRUST-MODEL.md +++ b/docs/TRUST-MODEL.md @@ -66,6 +66,18 @@ 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 c7e4a62..b141419 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,8 @@ go 1.25 require ( filippo.io/edwards25519 v1.1.0 github.com/btcsuite/btcd/btcutil v1.1.6 + github.com/coder/websocket v1.8.15 + 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 ef8e740..7e6367b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -49,6 +51,8 @@ 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= @@ -105,3 +109,5 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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/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 new file mode 100644 index 0000000..8558e79 --- /dev/null +++ b/internal/bft/bft.go @@ -0,0 +1,347 @@ +// 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 new file mode 100644 index 0000000..7cea0cd --- /dev/null +++ b/internal/bft/mesh_test.go @@ -0,0 +1,264 @@ +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 new file mode 100644 index 0000000..7fe4c8f --- /dev/null +++ b/internal/bft/validator.go @@ -0,0 +1,307 @@ +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 new file mode 100644 index 0000000..0cf2280 --- /dev/null +++ b/internal/checkpoint/checkpoint.go @@ -0,0 +1,154 @@ +// 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 new file mode 100644 index 0000000..53a3365 --- /dev/null +++ b/internal/checkpoint/checkpoint_test.go @@ -0,0 +1,126 @@ +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 68cff22..893874c 100644 --- a/internal/identity/invariants_test.go +++ b/internal/identity/invariants_test.go @@ -138,11 +138,19 @@ 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 package. +// to produce an Ed25519 signature must exist in exactly one place for trust +// statements, plus one narrowly-scoped exception. // -// Later stages will add server packages to this tree. Because the check scans -// every non-test file in the module, a server package that starts signing -// something will fail this test the moment it is written. +// 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. func TestNoSigningOutsideSigner(t *testing.T) { requireToolchain(t) files := goList(t, "-f", @@ -155,11 +163,29 @@ 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 private keys. + // 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) @@ -167,7 +193,8 @@ 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", file, api) + "private key operations must stay in internal/identity/signer "+ + "(or the relay's checkpoint transport key)", file, api) } } } diff --git a/internal/lightnode/auth.go b/internal/lightnode/auth.go new file mode 100644 index 0000000..18d7d8c --- /dev/null +++ b/internal/lightnode/auth.go @@ -0,0 +1,212 @@ +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 new file mode 100644 index 0000000..a8c01e2 --- /dev/null +++ b/internal/lightnode/handler.go @@ -0,0 +1,463 @@ +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 new file mode 100644 index 0000000..8297d87 --- /dev/null +++ b/internal/lightnode/integration_test.go @@ -0,0 +1,366 @@ +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("trust.n1ko.dev", 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 new file mode 100644 index 0000000..1e0f502 --- /dev/null +++ b/internal/lightnode/lightnode.go @@ -0,0 +1,262 @@ +// 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 new file mode 100644 index 0000000..0a23a53 --- /dev/null +++ b/internal/lightnode/ws.go @@ -0,0 +1,358 @@ +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 new file mode 100644 index 0000000..ec6bb44 --- /dev/null +++ b/internal/pow/pow.go @@ -0,0 +1,168 @@ +// 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 new file mode 100644 index 0000000..de11265 --- /dev/null +++ b/internal/pow/pow_test.go @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..e6e6539 --- /dev/null +++ b/internal/pow/vectors_test.go @@ -0,0 +1,133 @@ +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/protocol/accessors_test.go b/internal/protocol/accessors_test.go index 7c3cf9d..e5f8f7e 100644 --- a/internal/protocol/accessors_test.go +++ b/internal/protocol/accessors_test.go @@ -96,11 +96,11 @@ func TestValidateCurrentEdges(t *testing.T) { wantErr bool }{ {1000, 2000, 1500, false}, - {1000, 0, 1500, false}, // no expiry - {1000, 2000, 1000 - skew - 1, true}, // not yet valid (beyond skew) - {1000, 2000, 1000 - skew, false}, // exactly at skew: ok - {1000, 2000, 2000 + skew, false}, // still current within skew - {1000, 2000, 2000 + skew + 1, true}, // expired beyond skew + {1000, 0, 1500, false}, // no expiry + {1000, 2000, 1000 - skew - 1, true}, // not yet valid (beyond skew) + {1000, 2000, 1000 - skew, false}, // exactly at skew: ok + {1000, 2000, 2000 + skew, false}, // still current within skew + {1000, 2000, 2000 + skew + 1, true}, // expired beyond skew } for i, c := range cases { err := protocol.ValidateCurrent(c.created, c.expires, c.now) diff --git a/internal/protocol/decode.go b/internal/protocol/decode.go index 0b67919..c82a6e9 100644 --- a/internal/protocol/decode.go +++ b/internal/protocol/decode.go @@ -337,3 +337,76 @@ func DecodeAuthAssertion(b []byte) (*AuthAssertion, error) { o.tce = bytes.Clone(b) return o, nil } + +// DecodeDelegationClaim parses a DelegationClaim from its canonical bytes. +func DecodeDelegationClaim(b []byte) (*DelegationClaim, error) { + if len(b) > tce.MaxDelegTCE { + return nil, tce.ErrObjectTooLarge + } + d := tce.NewDecoder(b) + tag, err := d.Header() + if err != nil { + return nil, err + } + if tag != tce.TagDelegation { + return nil, ErrWrongObject + } + granter, err := decodeIdentityField(d, "granter") + if err != nil { + return nil, err + } + grantee, err := decodeIdentityField(d, "grantee") + if err != nil { + return nil, err + } + predRaw, err := d.Map(1) + if err != nil { + return nil, fieldErr("predicates", err) + } + predicates := make(map[string]tce.Value, len(predRaw)) + for k, v := range predRaw { + bv, ok := v.Bool() + if !ok || !bv { + return nil, fieldErr("predicates:"+k, tce.ErrValueTag) + } + predicates[k] = v + } + maxDepth, err := d.Uvarint() + if err != nil { + return nil, fieldErr("max_depth", err) + } + createdAt, err := d.Timestamp(false) + if err != nil { + return nil, fieldErr("created_at", err) + } + expiresAt, err := d.Timestamp(true) + if err != nil { + return nil, fieldErr("expires_at", err) + } + if expiresAt != 0 && expiresAt <= createdAt { + return nil, fieldErr("expires_at", tce.ErrExpiry) + } + serial, err := d.Uvarint() + if err != nil { + return nil, fieldErr("serial", err) + } + nonce, err := d.FixedBytes(tce.NonceSize) + if err != nil { + return nil, fieldErr("nonce", err) + } + if err := checkEnd(d); err != nil { + return nil, err + } + o := &DelegationClaim{ + Granter: granter, + Grantee: grantee, + Predicates: predicates, + MaxDepth: maxDepth, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + Serial: serial, + Nonce: nonce, + } + o.tce = bytes.Clone(b) + return o, nil +} diff --git a/internal/protocol/encode.go b/internal/protocol/encode.go index 3062444..06681c6 100644 --- a/internal/protocol/encode.go +++ b/internal/protocol/encode.go @@ -207,3 +207,48 @@ func EncodeAuthAssertion(o *AuthAssertion) ([]byte, error) { e.Timestamp("created_at", o.CreatedAt, false) return finishEncode(e, tce.MaxAuthTCE) } + +// EncodeDelegationClaim encodes a DelegationClaim, object tag 0x07. +// +// Field order: granter, grantee, predicates, max_depth, created_at, +// expires_at, serial, nonce. Every predicate value must be the boolean true: +// a delegation covers a key or it does not, and any other spelling would +// make two implementations disagree about coverage while both saw a valid +// signature. +func EncodeDelegationClaim(o *DelegationClaim) ([]byte, error) { + if o == nil { + return nil, ErrNil + } + if err := address.ValidatePubKey(o.Granter); err != nil { + return nil, fieldErr("granter", err) + } + if err := address.ValidatePubKey(o.Grantee); err != nil { + return nil, fieldErr("grantee", err) + } + if len(o.Nonce) != tce.NonceSize { + return nil, fieldErr("nonce", tce.ErrFieldSize) + } + if o.ExpiresAt != 0 && o.ExpiresAt <= o.CreatedAt { + return nil, fieldErr("expires_at", tce.ErrExpiry) + } + if len(o.Predicates) < 1 || len(o.Predicates) > tce.MaxMapEntries { + return nil, fieldErr("predicates", tce.ErrEmptyMap) + } + for k, v := range o.Predicates { + b, ok := v.Bool() + if !ok || !b { + return nil, fieldErr("predicates:"+k, tce.ErrValueTag) + } + } + e := tce.NewEncoder() + e.Header(tce.TagDelegation) + e.Identity("granter", o.Granter) + e.Identity("grantee", o.Grantee) + e.Map("predicates", o.Predicates, 1) + e.Uvarint(o.MaxDepth) + e.Timestamp("created_at", o.CreatedAt, false) + e.Timestamp("expires_at", o.ExpiresAt, true) + e.Uvarint(o.Serial) + e.FixedBytes("nonce", o.Nonce, tce.NonceSize) + return finishEncode(e, tce.MaxDelegTCE) +} diff --git a/internal/protocol/fuzz_test.go b/internal/protocol/fuzz_test.go index 1bcf10a..ee28882 100644 --- a/internal/protocol/fuzz_test.go +++ b/internal/protocol/fuzz_test.go @@ -60,12 +60,22 @@ func FuzzDecodeIsTotalAndNonMalleable(f *testing.F) { {"assertion", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeAuthAssertion(b) }}, } encode := map[string]func(interface{ TCE() []byte }) ([]byte, error){ - "identity": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeIdentity(o.(*protocol.Identity)) }, - "claim": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeClaim(o.(*protocol.Claim)) }, - "revocation": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeRevocation(o.(*protocol.Revocation)) }, - "request": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeApprovalRequest(o.(*protocol.ApprovalRequest)) }, - "response": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeApprovalResponse(o.(*protocol.ApprovalResponse)) }, - "assertion": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeAuthAssertion(o.(*protocol.AuthAssertion)) }, + "identity": func(o interface{ TCE() []byte }) ([]byte, error) { + return protocol.EncodeIdentity(o.(*protocol.Identity)) + }, + "claim": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeClaim(o.(*protocol.Claim)) }, + "revocation": func(o interface{ TCE() []byte }) ([]byte, error) { + return protocol.EncodeRevocation(o.(*protocol.Revocation)) + }, + "request": func(o interface{ TCE() []byte }) ([]byte, error) { + return protocol.EncodeApprovalRequest(o.(*protocol.ApprovalRequest)) + }, + "response": func(o interface{ TCE() []byte }) ([]byte, error) { + return protocol.EncodeApprovalResponse(o.(*protocol.ApprovalResponse)) + }, + "assertion": func(o interface{ TCE() []byte }) ([]byte, error) { + return protocol.EncodeAuthAssertion(o.(*protocol.AuthAssertion)) + }, } f.Fuzz(func(t *testing.T, b []byte) { @@ -122,7 +132,7 @@ func FuzzClaimBuildRoundTrip(f *testing.F) { n := 1 + int(byteAt(0))%3 for i := 0; i < n; i++ { key := "k" + string(rune('a'+i)) - switch byteAt(i + 1) % 5 { + switch byteAt(i+1) % 5 { case 0: claims[key] = tce.Null() case 1: @@ -163,4 +173,4 @@ func FuzzClaimBuildRoundTrip(f *testing.F) { t.Fatal("object ID unstable across a decode/encode cycle") } }) -} \ No newline at end of file +} diff --git a/internal/protocol/invariants_test.go b/internal/protocol/invariants_test.go index 3e50612..770c738 100644 --- a/internal/protocol/invariants_test.go +++ b/internal/protocol/invariants_test.go @@ -79,10 +79,10 @@ func TestAllowedImports(t *testing.T) { "bytes": true, "crypto/ed25519": true, // Verify only; signing lives in internal/identity/signer "crypto/subtle": true, - "errors": true, - "fmt": true, + "errors": true, + "fmt": true, "git.n1ko.dev/Niko/niko_trust/internal/address": true, - "git.n1ko.dev/Niko/niko_trust/internal/tce": true, + "git.n1ko.dev/Niko/niko_trust/internal/tce": true, } for _, f := range scanPackageProtocol(t, ".") { for _, imp := range importsOfProtocol(t, f) { diff --git a/internal/protocol/mutation_test.go b/internal/protocol/mutation_test.go index a1808af..6356d1d 100644 --- a/internal/protocol/mutation_test.go +++ b/internal/protocol/mutation_test.go @@ -14,7 +14,7 @@ import ( // decoded object, a mutation that survived round-tripping would pass. // verifyFor returns an error when the given vector no longer verifies. -func verifyFor(t *testing.T, name string, reqTce, reqSig, tceB, sig []byte) error { +func verifyFor(t *testing.T, name string, reqTce, reqSig, rotTce, rotSig, tceB, sig []byte) error { t.Helper() switch name { case "identity/nikocraft", "identity/niko": @@ -35,6 +35,15 @@ func verifyFor(t *testing.T, name string, reqTce, reqSig, tceB, sig []byte) erro case "auth_assertion/ws": _, err := protocol.VerifyAuthAssertion(tceB, sig, "trust.n1ko.dev") return err + case "delegation/minimal", "delegation/multi-predicate": + _, err := protocol.VerifyDelegationClaim(tceB, sig) + return err + case "key_rotation/request": + _, err := protocol.VerifyKeyRotationRequest(tceB, sig) + return err + case "key_rotation/confirm": + _, err := protocol.VerifyKeyRotationConfirm(rotTce, rotSig, tceB, sig) + return err } return nil } @@ -48,6 +57,9 @@ func TestMutationSweep(t *testing.T) { req := byName["approval_request/ban"] reqTce := mustHex(t, req.TCEHex) reqSig := mustHex(t, req.SignatureHex) + rot := byName["key_rotation/request"] + rotTce := mustHex(t, rot.TCEHex) + rotSig := mustHex(t, rot.SignatureHex) for _, v := range vf.Vectors { t.Run(v.Name, func(t *testing.T) { @@ -55,7 +67,7 @@ func TestMutationSweep(t *testing.T) { sig := mustHex(t, v.SignatureHex) // The unchanged vector must verify. - if err := verifyFor(t, v.Name, reqTce, reqSig, b, sig); err != nil { + if err := verifyFor(t, v.Name, reqTce, reqSig, rotTce, rotSig, b, sig); err != nil { t.Fatalf("baseline does not verify: %v", err) } @@ -64,7 +76,7 @@ func TestMutationSweep(t *testing.T) { for _, mask := range []byte{0x01, 0x80} { mut := append([]byte{}, b...) mut[i] ^= mask - if err := verifyFor(t, v.Name, reqTce, reqSig, mut, sig); err == nil { + if err := verifyFor(t, v.Name, reqTce, reqSig, rotTce, rotSig, mut, sig); err == nil { t.Fatalf("verified TCE with byte %d flipped (mask 0x%02x)", i, mask) } } @@ -74,7 +86,7 @@ func TestMutationSweep(t *testing.T) { for i := 0; i < len(sig); i++ { mut := append([]byte{}, sig...) mut[i] ^= 0x01 - if err := verifyFor(t, v.Name, reqTce, reqSig, b, mut); err == nil { + if err := verifyFor(t, v.Name, reqTce, reqSig, rotTce, rotSig, b, mut); err == nil { t.Fatalf("verified with signature byte %d flipped", i) } } diff --git a/internal/protocol/objects.go b/internal/protocol/objects.go index dca924b..382c558 100644 --- a/internal/protocol/objects.go +++ b/internal/protocol/objects.go @@ -333,3 +333,64 @@ func (o *AuthAssertion) TCE() []byte { return bytes.Clone(o.tce) } // Signature returns a copy of the signature verified over the canonical // bytes, or nil if none was verified. func (o *AuthAssertion) Signature() []byte { return bytes.Clone(o.sig) } + +// DelegationClaim grants another identity the right to speak, object tag +// 0x07. +// +// The granter asserts: "grantee may issue claims whose predicate is one of +// Predicates, as if I had issued them myself, re-delegating at most MaxDepth +// further times." Resolution — walking a chain of such objects from a root +// the verifier already trusts down to a claim's actual issuer — lives in +// internal/verify. The protocol layer only establishes who granted what to +// whom and that the grant is intact. +// +// A delegation is revoked exactly like a claim: a Revocation signed by the +// granter targeting this object's ID. +type DelegationClaim struct { + // Granter signs this object and lends its authority. + Granter []byte + + // Grantee is the identity allowed to speak under the grant. + Grantee []byte + + // Predicates are the exact claim keys covered, each with value true. + // At least one entry, at most 32; prefix or wildcard matching is + // deliberately not a thing (docs/DELEGATION.md). + Predicates map[string]tce.Value + + // MaxDepth is how many further delegation hops may sit below this one. + // Zero means the grantee speaks for itself only. + MaxDepth uint64 + + CreatedAt uint64 + + // ExpiresAt is 0 for a grant that does not expire, otherwise strictly + // after CreatedAt. + ExpiresAt uint64 + + // Serial lets the granter supersede an earlier grant with new terms. + Serial uint64 + + Nonce []byte + + tce []byte + sig []byte +} + +// TCE returns a copy of the canonical bytes this delegation was decoded +// from, or nil for one constructed in memory. +func (o *DelegationClaim) TCE() []byte { return bytes.Clone(o.tce) } + +// Signature returns a copy of the signature verified over the canonical +// bytes, or nil if none was verified. +func (o *DelegationClaim) Signature() []byte { return bytes.Clone(o.sig) } + +// Covers reports whether p is among the delegated predicates. +func (o *DelegationClaim) Covers(p string) bool { + v, ok := o.Predicates[p] + if !ok { + return false + } + b, isBool := v.Bool() + return isBool && b +} diff --git a/internal/protocol/rotation.go b/internal/protocol/rotation.go new file mode 100644 index 0000000..5429ad5 --- /dev/null +++ b/internal/protocol/rotation.go @@ -0,0 +1,246 @@ +package protocol + +// Key rotation: how an identity changes its key without losing its history. +// +// Two linked objects, mirroring the approval pattern: +// +// KeyRotationRequest (tag 0x08) — signed by the SUCCESSOR. +// "I, this key, succeed predecessor." +// KeyRotationConfirm (tag 0x09) — signed by the PREDECESSOR. +// "I consent to that exact request." +// +// Neither object alone proves anything: a request names any predecessor it +// likes, and a consent is unintelligible without the request it hashes. The +// binding is by content ID (INV-4), so a signed decision can never be moved +// to a different request. Together they are evidence both keys agreed. +// +// Freshness over long horizons is the verifier's business +// (Policy.RotationMaxAge), not the wire's: the request lives at most sixty +// seconds, exactly like an approval request. + +import ( + "bytes" + "crypto/subtle" + + "git.n1ko.dev/Niko/niko_trust/internal/address" + "git.n1ko.dev/Niko/niko_trust/internal/tce" +) + +// KeyRotationRequest is the incoming key's claim of succession, tag 0x08. +type KeyRotationRequest struct { + // Successor signs this object and becomes the identity's new key once + // the rotation is accepted. + Successor []byte + + // Predecessor is the key being succeeded and the only identity whose + // confirmation counts. + Predecessor []byte + + CreatedAt uint64 + + // ExpiresAt must be after CreatedAt by at most 60 seconds. + ExpiresAt uint64 + + tce []byte + sig []byte +} + +func (o *KeyRotationRequest) TCE() []byte { return bytes.Clone(o.tce) } + +func (o *KeyRotationRequest) Signature() []byte { return bytes.Clone(o.sig) } + +// KeyRotationConfirm is the predecessor's signed consent, tag 0x09. +type KeyRotationConfirm struct { + // RotationHash is the object ID of the exact canonical request bytes. + RotationHash tce.ID + + CreatedAt uint64 + + Nonce []byte + + tce []byte + sig []byte +} + +func (o *KeyRotationConfirm) TCE() []byte { return bytes.Clone(o.tce) } + +func (o *KeyRotationConfirm) Signature() []byte { return bytes.Clone(o.sig) } + +// -------------------------------------------------------------------- codec + +// EncodeKeyRotationRequest encodes tag 0x08: successor, predecessor, +// created_at, expires_at. +func EncodeKeyRotationRequest(o *KeyRotationRequest) ([]byte, error) { + if o == nil { + return nil, ErrNil + } + if err := address.ValidatePubKey(o.Successor); err != nil { + return nil, fieldErr("successor", err) + } + if err := address.ValidatePubKey(o.Predecessor); err != nil { + return nil, fieldErr("predecessor", err) + } + if o.ExpiresAt <= o.CreatedAt { + return nil, fieldErr("expires_at", tce.ErrExpiry) + } + if o.ExpiresAt-o.CreatedAt > tce.MaxApprovalLifetime { + return nil, fieldErr("expires_at", tce.ErrExpiry) + } + e := tce.NewEncoder() + e.Header(tce.TagKeyRotation) + e.Identity("successor", o.Successor) + e.Identity("predecessor", o.Predecessor) + e.Timestamp("created_at", o.CreatedAt, false) + e.Timestamp("expires_at", o.ExpiresAt, false) + return finishEncode(e, tce.MaxKeyRotTCE) +} + +// DecodeKeyRotationRequest parses canonical bytes of tag 0x08. +func DecodeKeyRotationRequest(b []byte) (*KeyRotationRequest, error) { + if len(b) > tce.MaxKeyRotTCE { + return nil, tce.ErrObjectTooLarge + } + d := tce.NewDecoder(b) + tag, err := d.Header() + if err != nil { + return nil, err + } + if tag != tce.TagKeyRotation { + return nil, ErrWrongObject + } + successor, err := decodeIdentityField(d, "successor") + if err != nil { + return nil, err + } + predecessor, err := decodeIdentityField(d, "predecessor") + if err != nil { + return nil, err + } + createdAt, err := d.Timestamp(false) + if err != nil { + return nil, fieldErr("created_at", err) + } + expiresAt, err := d.Timestamp(false) + if err != nil { + return nil, fieldErr("expires_at", err) + } + if expiresAt <= createdAt || expiresAt-createdAt > tce.MaxApprovalLifetime { + return nil, fieldErr("expires_at", tce.ErrExpiry) + } + if err := checkEnd(d); err != nil { + return nil, err + } + o := &KeyRotationRequest{ + Successor: successor, + Predecessor: predecessor, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + } + o.tce = bytes.Clone(b) + return o, nil +} + +// EncodeKeyRotationConfirm encodes tag 0x09: rotation_hash, created_at, +// nonce. +func EncodeKeyRotationConfirm(o *KeyRotationConfirm) ([]byte, error) { + if o == nil { + return nil, ErrNil + } + if len(o.Nonce) != tce.NonceSize { + return nil, fieldErr("nonce", tce.ErrFieldSize) + } + e := tce.NewEncoder() + e.Header(tce.TagKeyRotationConf) + e.FixedBytes("rotation_hash", o.RotationHash[:], tce.HashSize) + e.Timestamp("created_at", o.CreatedAt, false) + e.FixedBytes("nonce", o.Nonce, tce.NonceSize) + return finishEncode(e, tce.MaxKeyRotTCE) +} + +// DecodeKeyRotationConfirm parses canonical bytes of tag 0x09. +func DecodeKeyRotationConfirm(b []byte) (*KeyRotationConfirm, error) { + if len(b) > tce.MaxKeyRotTCE { + return nil, tce.ErrObjectTooLarge + } + d := tce.NewDecoder(b) + tag, err := d.Header() + if err != nil { + return nil, err + } + if tag != tce.TagKeyRotationConf { + return nil, ErrWrongObject + } + hash, err := d.FixedBytes(tce.HashSize) + if err != nil { + return nil, fieldErr("rotation_hash", err) + } + var o KeyRotationConfirm + copy(o.RotationHash[:], hash) + if o.CreatedAt, err = d.Timestamp(false); err != nil { + return nil, fieldErr("created_at", err) + } + nonce, err := d.FixedBytes(tce.NonceSize) + if err != nil { + return nil, fieldErr("nonce", err) + } + o.Nonce = nonce + if err := checkEnd(d); err != nil { + return nil, err + } + o.tce = bytes.Clone(b) + return &o, nil +} + +// --------------------------------------------------------------- verifying + +// VerifyKeyRotationRequest decodes and checks the signature under the +// successor's key. +func VerifyKeyRotationRequest(tceBytes, sig []byte) (*KeyRotationRequest, error) { + o, err := DecodeKeyRotationRequest(tceBytes) + if err != nil { + return nil, err + } + if err := verifySignature(o.Successor, o.tce, sig); err != nil { + return nil, err + } + o.sig = make([]byte, len(sig)) + copy(o.sig, sig) + return o, nil +} + +// VerifyKeyRotationConfirm decodes and checks the signature under the +// successor-named predecessor. The confirm is only meaningful bound to its +// request; there is deliberately no standalone acceptance. +func VerifyKeyRotationConfirm(reqTCE, reqSig, confTCE, confSig []byte) (*KeyRotationConfirm, error) { + req, err := VerifyKeyRotationRequest(reqTCE, reqSig) + if err != nil { + return nil, err + } + conf, err := DecodeKeyRotationConfirm(confTCE) + if err != nil { + return nil, err + } + if err := verifySignature(req.Predecessor, conf.tce, confSig); err != nil { + return nil, err + } + // The consent commits to the full canonical request (INV-4). + want := tce.ComputeID(req.tce) + if subtle.ConstantTimeCompare(conf.RotationHash[:], want[:]) != 1 { + return nil, ErrRequestMismatch + } + conf.sig = make([]byte, len(confSig)) + copy(conf.sig, confSig) + return conf, nil +} + +// RotationWindowOK applies the approval-style tolerance interpretation: the +// confirm must sit within [req.created − skew, req.expires + skew]. +func RotationWindowOK(req *KeyRotationRequest, conf *KeyRotationConfirm) bool { + if req.CreatedAt > conf.CreatedAt && req.CreatedAt-conf.CreatedAt > MaxClockSkew { + return false + } + if req.ExpiresAt < conf.CreatedAt && conf.CreatedAt-req.ExpiresAt > MaxClockSkew { + return false + } + return true +} diff --git a/internal/protocol/vectors_test.go b/internal/protocol/vectors_test.go index 6b0873a..3106467 100644 --- a/internal/protocol/vectors_test.go +++ b/internal/protocol/vectors_test.go @@ -79,7 +79,7 @@ func TestVectorPartiesDerivedFromSeeds(t *testing.T) { func TestVectorGolden(t *testing.T) { vf := loadVectors(t) - if len(vf.Vectors) != 9 { + if len(vf.Vectors) != 13 { t.Fatalf("expect 9 object vectors, got %d", len(vf.Vectors)) } @@ -89,6 +89,7 @@ func TestVectorGolden(t *testing.T) { } claimBool := byName["claim/boolean"] req := byName["approval_request/ban"] + rotReq := byName["key_rotation/request"] var revClaim *protocol.Claim var rev *protocol.Revocation @@ -196,6 +197,46 @@ func TestVectorGolden(t *testing.T) { t.Errorf("challenge mismatch") } + case "delegation": + obj, err := protocol.VerifyDelegationClaim(tceBytes, sig) + if err != nil { + t.Fatalf("VerifyDelegationClaim: %v", err) + } + reencodeAndCompare(t, "delegation", mustTCE(t, obj), tceBytes) + checkAddress(t, obj.Granter, v.SignerAddress) + if len(obj.Predicates) < 1 || len(obj.Predicates) > 32 { + t.Errorf("predicates size %d out of bounds", len(obj.Predicates)) + } + for k := range obj.Predicates { + if !obj.Covers(k) { + t.Errorf("Covers(%q) false for a delegated predicate", k) + } + } + case "key_rotation_request": + obj, err := protocol.VerifyKeyRotationRequest(tceBytes, sig) + if err != nil { + t.Fatalf("VerifyKeyRotationRequest: %v", err) + } + reencodeAndCompare(t, "key_rotation_request", mustTCE(t, obj), tceBytes) + checkAddress(t, obj.Successor, v.SignerAddress) + if obj.ExpiresAt-obj.CreatedAt > 60 { + t.Errorf("lifetime %d exceeds the format bound", obj.ExpiresAt-obj.CreatedAt) + } + + case "key_rotation_confirm": + reqBytes := mustHex(t, rotReq.TCEHex) + reqSig := mustHex(t, rotReq.SignatureHex) + obj, err := protocol.VerifyKeyRotationConfirm(reqBytes, reqSig, tceBytes, sig) + if err != nil { + t.Fatalf("VerifyKeyRotationConfirm: %v", err) + } + reencodeAndCompare(t, "key_rotation_confirm", mustTCE(t, obj), tceBytes) + wantHash := rotReq.ObjectIDHex + if obj.RotationHash.String() != wantHash { + t.Errorf("rotation_hash does not match the request object ID") + } + _ = wantHash + default: t.Fatalf("unknown json type %q", typ.Type) } diff --git a/internal/protocol/verify.go b/internal/protocol/verify.go index e3ee8dd..895bccf 100644 --- a/internal/protocol/verify.go +++ b/internal/protocol/verify.go @@ -293,3 +293,47 @@ func ClaimStatusAt(o *Claim, now uint64) ClaimStatus { } return StatusActive } + +// VerifyDelegationClaim decodes the canonical bytes and checks the signature +// under the granter's public key. +func VerifyDelegationClaim(tceBytes, sig []byte) (*DelegationClaim, error) { + o, err := DecodeDelegationClaim(tceBytes) + if err != nil { + return nil, err + } + if err := verifySignature(o.Granter, o.tce, sig); err != nil { + return nil, err + } + o.sig = make([]byte, len(sig)) + copy(o.sig, sig) + return o, nil +} + +// VerifyRevocationOfDelegation reports whether rev withdraws the delegation +// cp: only the granter may revoke its own grant, and the revocation must +// target this exact object by content ID. +func VerifyRevocationOfDelegation(rev *Revocation, cp *DelegationClaim) error { + if rev == nil || cp == nil { + return ErrNil + } + if subtle.ConstantTimeCompare(rev.Issuer, cp.Granter) != 1 { + return ErrWrongIssuer + } + if rev.ClaimID.String() != tce.ComputeID(cp.tce).String() { + return ErrWrongClaim + } + return nil +} + +// DelegationStatusAt returns the current validity of a grant at time now, +// without consulting any revocation store. Same three-valued semantics as +// ClaimStatusAt: absence is not a protocol state. +func DelegationStatusAt(cp *DelegationClaim, now uint64) ClaimStatus { + if cp == nil { + return StatusActive + } + if cp.ExpiresAt != 0 && now > cp.ExpiresAt && now-cp.ExpiresAt > MaxClockSkew { + return StatusExpired + } + return StatusActive +} diff --git a/internal/server/bft.go b/internal/server/bft.go new file mode 100644 index 0000000..c9fd9d0 --- /dev/null +++ b/internal/server/bft.go @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..0b42979 --- /dev/null +++ b/internal/server/checkpoint.go @@ -0,0 +1,397 @@ +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 new file mode 100644 index 0000000..fd2f28d --- /dev/null +++ b/internal/server/checkpoint_test.go @@ -0,0 +1,327 @@ +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("trust.n1ko.dev", 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("trust.n1ko.dev", 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("trust.n1ko.dev", 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 new file mode 100644 index 0000000..3bc9c83 --- /dev/null +++ b/internal/server/gossip.go @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..e1ba725 --- /dev/null +++ b/internal/server/gossip_test.go @@ -0,0 +1,140 @@ +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("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("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("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/invariants_test.go b/internal/server/invariants_test.go index 57b99c8..52e2a3d 100644 --- a/internal/server/invariants_test.go +++ b/internal/server/invariants_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" ) @@ -21,7 +22,7 @@ func TestServerDoesNotImportSigner(t *testing.T) { if e.IsDir() || filepath.Ext(e.Name()) != ".go" { continue } - if e.Name()[len(e.Name())-8:] == "_test.go" { + if strings.HasSuffix(e.Name(), "_test.go") { continue } fset := token.NewFileSet() diff --git a/internal/server/metrics.go b/internal/server/metrics.go index 47d532b..15c5de2 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -3,6 +3,8 @@ package server import ( "fmt" "io" + "sort" + "strings" "sync/atomic" ) @@ -10,14 +12,47 @@ 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 + 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 + + // objectsByType holds one counter per known object type name. The map is + // populated once at construction and never mutated afterwards, so + // concurrent reads are safe. + objectsByType map[string]*atomic.Uint64 +} + +// NewMetrics returns metrics with the per-type counters preallocated. +func NewMetrics() *Metrics { + m := &Metrics{objectsByType: make(map[string]*atomic.Uint64)} + for _, name := range []string{ + "identity", "claim", "revocation", "approval_request", + "approval_response", "auth_assertion", + } { + m.objectsByType[name] = &atomic.Uint64{} + } + return m +} + +// incStoredType records one accepted object of the given type. +func (m *Metrics) incStoredType(typ string) { + if c, ok := m.objectsByType[typ]; ok { + c.Add(1) + } } // inc records an event. @@ -39,4 +74,42 @@ 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") + fmt.Fprintf(w, "trust_ws_messages_sent %d\n", m.wsMessagesSent.Load()) + for _, typ := range sortedTypeNames(m.objectsByType) { + name := "trust_objects_stored_" + sanitizeMetricName(typ) + fmt.Fprintf(w, "# TYPE %s counter\n", name) + fmt.Fprintf(w, "%s %d\n", name, m.objectsByType[typ].Load()) + } +} + +// sortedTypeNames renders the per-type counters in stable order. +func sortedTypeNames(byType map[string]*atomic.Uint64) []string { + names := make([]string, 0, len(byType)) + for k := range byType { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// sanitizeMetricName maps type names into metric-safe labels. +func sanitizeMetricName(s string) string { + return strings.NewReplacer("-", "_").Replace(s) } diff --git a/internal/server/pow.go b/internal/server/pow.go new file mode 100644 index 0000000..8f8149c --- /dev/null +++ b/internal/server/pow.go @@ -0,0 +1,171 @@ +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 new file mode 100644 index 0000000..398027f --- /dev/null +++ b/internal/server/pow_test.go @@ -0,0 +1,295 @@ +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("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 f15311c..e0680c3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,10 +1,12 @@ package server import ( + "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" + "io" "net/http" "strconv" "strings" @@ -12,6 +14,7 @@ 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" @@ -37,12 +40,31 @@ type Server struct { metrics *Metrics ready bool - putLimiter *ipLimiter - challengeLimiter *ipLimiter + putLimiter *ipLimiter + challengeLimiter *ipLimiter + powChallengeLimiter *ipLimiter - mu sync.Mutex - challenges map[string]time.Time // challenge hex -> expiry - sessions map[string]session // session token -> session + powPutBits int + powAuthBits int + + mu sync.Mutex + challenges map[string]time.Time // challenge hex -> expiry + powChallenges map[string]powChallengeRecord // pow key hex -> issued challenge + sessions map[string]session // session token -> session + + // ckpt publishes the signed object-set commitment; nil when disabled. + ckpt *checkpointState + + // gossip records heads other relays have announced, for split-view + // detection. Present even when checkpoints are disabled locally: a node + // can relay others' claims about their own logs. + gossip *gossipState + + // ws streams newly stored envelopes to subscribed sessions. + ws *wsHubState + + // bft is the optional finality validator state; nil unless enabled. + bft *bftState } type session struct { @@ -54,15 +76,22 @@ type session struct { // New builds a relay that binds AuthAssertions to the given audience (its // hostname, e.g. "trust.n1ko.dev"). If dataDir is non-empty, objects are // persisted there across restarts. -func New(audience, dataDir string) *Server { +func New(audience, dataDir string, opts ...Option) *Server { s := &Server{ - store: NewStore(dataDir), - audience: audience, - metrics: &Metrics{}, - challenges: make(map[string]time.Time), - sessions: make(map[string]session), - putLimiter: newIPLimiter(60, time.Minute), - challengeLimiter: newIPLimiter(30, time.Minute), + store: NewStore(dataDir), + audience: audience, + metrics: NewMetrics(), + challenges: make(map[string]time.Time), + powChallenges: make(map[string]powChallengeRecord), + sessions: make(map[string]session), + gossip: newGossipState(), + ws: newWSHub(), + putLimiter: newIPLimiter(60, time.Minute), + challengeLimiter: newIPLimiter(30, time.Minute), + powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute), + } + for _, opt := range opts { + opt(s) } s.ready = true return s @@ -76,6 +105,7 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /v1/objects", s.rateLimitPut) mux.HandleFunc("GET /v1/objects/{id}", s.handleGet) + mux.HandleFunc("GET /v1/objects", s.handleObjectsBatch) mux.HandleFunc("GET /v1/claims", s.handleClaims) mux.HandleFunc("GET /v1/requests", s.handleRequests) mux.HandleFunc("GET /v1/responses", s.handleResponses) @@ -86,6 +116,18 @@ 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 } @@ -127,22 +169,40 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { } func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) { - env, ok := decodeEnvelope(w, r) + req, ok := decodeWire(w, r) if !ok { return } - if len(env.TCE) == 0 { + if len(req.TCE) == 0 { writeErr(w, http.StatusBadRequest, "missing tce") return } - id, err := s.store.Put(env.TCE, env.Signature, env.ObjectID) + // 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) writeErr(w, http.StatusUnprocessableEntity, err.Error()) return } s.metrics.inc(&s.metrics.objectsStored) - writeJSON(w, http.StatusOK, map[string]string{"object_id": id}) + if created { + if typ, obj, derr := transport.DecodeObject(req.TCE); derr == nil { + s.metrics.incStoredType(typ) + if created { + s.notifyCheckpoint() + s.hubBroadcastObj(typ, obj, req.TCE, req.Signature, objectID) + } + } + } + writeJSON(w, http.StatusOK, map[string]string{"object_id": objectID}) } func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { @@ -162,9 +222,20 @@ func scopeOK(ses session, required string) bool { return ses.scope == required || ses.scope == "read" || ses.scope == "*" } -// paginate applies optional limit/offset query params to a result list. A -// non-positive limit means "no cap". -func paginate(in []*transport.Envelope, limit, offset int) []*transport.Envelope { +// paginate applies optional limit/offset/after query params to a result +// list. A non-positive limit means "no cap". `after` keeps only entries +// whose content id is lexicographically greater, giving clients a stable +// cursor across concurrent inserts. +func paginate(in []*transport.Envelope, limit, offset int, after string) []*transport.Envelope { + if after != "" { + kept := in[:0] + for _, env := range in { + if tce.ComputeID(env.TCE).String() > after { + kept = append(kept, env) + } + } + in = kept + } if offset > 0 { if offset >= len(in) { return nil @@ -177,8 +248,8 @@ func paginate(in []*transport.Envelope, limit, offset int) []*transport.Envelope return in } -// listParams reads limit/offset from the query string. -func listParams(r *http.Request) (limit, offset int) { +// listParams reads limit/offset/after from the query string. +func listParams(r *http.Request) (limit, offset int, after string) { if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil { limit = n @@ -189,7 +260,43 @@ func listParams(r *http.Request) (limit, offset int) { offset = n } } - return + return limit, offset, r.URL.Query().Get("after") +} + +const maxBatchIDs = 100 + +// handleObjectsBatch serves GET /v1/objects?ids=a,b,c — one round trip for +// consumers that already know which objects they need. Missing ids are +// omitted rather than erroring. +func (s *Server) handleObjectsBatch(w http.ResponseWriter, r *http.Request) { + raw := r.URL.Query().Get("ids") + if raw == "" { + writeErr(w, http.StatusBadRequest, "missing ids") + return + } + parts := strings.Split(raw, ",") + if len(parts) > maxBatchIDs { + writeErr(w, http.StatusBadRequest, "too many ids") + return + } + clean := make([]string, 0, len(parts)) + for _, id := range parts { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, err := tce.ParseID(id); err != nil { + writeErr(w, http.StatusBadRequest, "malformed object id: "+id) + return + } + clean = append(clean, id) + } + found, err := s.store.GetMany(clean) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"objects": found}) } func (s *Server) handleClaims(w http.ResponseWriter, r *http.Request) { @@ -212,8 +319,8 @@ func (s *Server) handleClaims(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err.Error()) return } - lim, off := listParams(r) - writeJSON(w, http.StatusOK, map[string]any{"claims": paginate(list, lim, off)}) + lim, off, after := listParams(r) + writeJSON(w, http.StatusOK, map[string]any{"claims": paginate(list, lim, off, after)}) } func (s *Server) handleRequests(w http.ResponseWriter, r *http.Request) { @@ -236,8 +343,8 @@ func (s *Server) handleRequests(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err.Error()) return } - lim, off := listParams(r) - writeJSON(w, http.StatusOK, map[string]any{"requests": paginate(list, lim, off)}) + lim, off, after := listParams(r) + writeJSON(w, http.StatusOK, map[string]any{"requests": paginate(list, lim, off, after)}) } func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { @@ -260,8 +367,8 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err.Error()) return } - lim, off := listParams(r) - writeJSON(w, http.StatusOK, map[string]any{"responses": paginate(list, lim, off)}) + lim, off, after := listParams(r) + writeJSON(w, http.StatusOK, map[string]any{"responses": paginate(list, lim, off, after)}) } func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) { @@ -284,15 +391,34 @@ func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, err.Error()) return } - lim, off := listParams(r) - writeJSON(w, http.StatusOK, map[string]any{"revocations": paginate(list, lim, off)}) + lim, off, after := listParams(r) + writeJSON(w, http.StatusOK, map[string]any{"revocations": paginate(list, lim, off, after)}) } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"audience": s.audience}) + out := map[string]string{"audience": s.audience} + if s.ckpt != nil { + out["relay_pubkey"] = s.ckpt.pubHex + } + writeJSON(w, http.StatusOK, out) } 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 := 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") @@ -406,12 +532,22 @@ func identityFromPubKey(pub []byte) string { return id.Address().String() } -// decodeEnvelope reads a JSON envelope from the request, enforcing the +// wireRequest is the JSON body of endpoints that carry a signed envelope: +// 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"` +} + +// decodeWire reads a JSON request body from the request, enforcing the // server-wide body cap first. -func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) { +func decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) - var env transport.Envelope - if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + var req wireRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeErr(w, http.StatusRequestEntityTooLarge, "payload too large") @@ -420,7 +556,26 @@ func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope } return nil, false } - return &env, true + return &req, true +} + +// decodeEnvelope adapts decodeWire to the plain envelope form for callers +// that do not care about admission control. +func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) { + req, ok := decodeWire(w, r) + if !ok { + return nil, false + } + return &transport.Envelope{TCE: req.TCE, Signature: req.Signature}, true +} + +// readCapped reads the request body under the server-wide cap. +func readCapped(w http.ResponseWriter, r *http.Request) []byte { + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + if err != nil { + return nil + } + return raw } func writeJSON(w http.ResponseWriter, code int, v any) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 82b8acb..4c8c75e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -15,9 +15,9 @@ import ( "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" "git.n1ko.dev/Niko/niko_trust/internal/protocol" "git.n1ko.dev/Niko/niko_trust/internal/server" - "git.n1ko.dev/Niko/niko_trust/internal/verify" "git.n1ko.dev/Niko/niko_trust/internal/tce" "git.n1ko.dev/Niko/niko_trust/internal/transport" + "git.n1ko.dev/Niko/niko_trust/internal/verify" ) func newTestServer(t *testing.T) *httptest.Server { @@ -47,7 +47,9 @@ func authToken(t *testing.T, baseURL string, key *signer.Signer) string { if err != nil { t.Fatal(err) } - var chRes struct{ Challenge string `json:"challenge"` } + var chRes struct { + Challenge string `json:"challenge"` + } _ = json.NewDecoder(cr.Body).Decode(&chRes) cr.Body.Close() ch, err := hex.DecodeString(chRes.Challenge) @@ -177,7 +179,9 @@ func TestAuthHandshake(t *testing.T) { if err != nil { t.Fatal(err) } - var chRes struct{ Challenge string `json:"challenge"` } + var chRes struct { + Challenge string `json:"challenge"` + } _ = json.NewDecoder(cr.Body).Decode(&chRes) cr.Body.Close() ch, err := hex.DecodeString(chRes.Challenge) @@ -335,7 +339,9 @@ func TestAssertChallengeIsSingleUse(t *testing.T) { client, _ := signer.Generate() - chRes := struct{ Challenge string `json:"challenge"` }{} + chRes := struct { + Challenge string `json:"challenge"` + }{} resp, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil) _ = json.NewDecoder(resp.Body).Decode(&chRes) resp.Body.Close() @@ -384,7 +390,9 @@ func TestClaimsInsufficientScope(t *testing.T) { client, _ := signer.Generate() // Authenticate with a scope that does not cover read:claims. cr, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil) - var chRes struct{ Challenge string `json:"challenge"` } + var chRes struct { + Challenge string `json:"challenge"` + } _ = json.NewDecoder(cr.Body).Decode(&chRes) cr.Body.Close() ch, _ := hex.DecodeString(chRes.Challenge) @@ -398,7 +406,9 @@ func TestClaimsInsufficientScope(t *testing.T) { tceBytes, _ := protocol.EncodeAuthAssertion(a) sig := client.Sign(tceBytes) ar := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig}) - var out struct{ SessionToken string `json:"session_token"` } + var out struct { + SessionToken string `json:"session_token"` + } _ = json.NewDecoder(ar.Body).Decode(&out) ar.Body.Close() @@ -428,7 +438,7 @@ func TestStorePersistsAcrossRestart(t *testing.T) { } tceBytes, _ := protocol.EncodeClaim(c) sig := issuer.Sign(tceBytes) - id, err := s1.Store().Put(tceBytes, sig, "") + id, _, err := s1.Store().Put(tceBytes, sig, "") if err != nil { t.Fatal(err) } @@ -454,7 +464,9 @@ func TestConfigReportsAudience(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("config status %d", resp.StatusCode) } - var cfg struct{ Audience string `json:"audience"` } + var cfg struct { + Audience string `json:"audience"` + } _ = json.NewDecoder(resp.Body).Decode(&cfg) resp.Body.Close() if cfg.Audience != "trust.n1ko.dev" { @@ -593,7 +605,9 @@ func sPut(ts *httptest.Server, tceBytes, sig []byte) (string, error) { resp.Body.Close() return "", fmt.Errorf("put status %d", resp.StatusCode) } - var out struct{ ObjectID string `json:"object_id"` } + var out struct { + ObjectID string `json:"object_id"` + } _ = json.NewDecoder(resp.Body).Decode(&out) resp.Body.Close() return out.ObjectID, nil diff --git a/internal/server/store.go b/internal/server/store.go index 8a40f61..41e5b40 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -18,11 +18,13 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "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" ) @@ -49,6 +51,16 @@ type Store struct { // answeredRequests records request hashes that already have a stored // response, to enforce the one-response-per-request rule. answeredRequests map[string]struct{} + + // confirmedRotations records rotation request ids that already have a + // stored confirm: competing consents make a link unusable rather than + // 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, @@ -60,15 +72,17 @@ const maxPerSubject = 1000 // from disk and every subsequent Put is persisted there. func NewStore(dir string) *Store { s := &Store{ - dir: dir, - byID: make(map[string]*transport.Envelope), - byType: make(map[string]map[string]struct{}), - bySubject: make(map[string]map[string]struct{}), + dir: dir, + byID: make(map[string]*transport.Envelope), + byType: make(map[string]map[string]struct{}), + bySubject: make(map[string]map[string]struct{}), byRecipient: make(map[string]map[string]struct{}), - byClaimID: make(map[string]map[string]struct{}), - byRequest: make(map[string]map[string]struct{}), + byClaimID: make(map[string]map[string]struct{}), + byRequest: make(map[string]map[string]struct{}), - answeredRequests: make(map[string]struct{}), + answeredRequests: make(map[string]struct{}), + confirmedRotations: make(map[string]struct{}), + trie: smt.New(), } if dir != "" { s.load() @@ -77,50 +91,58 @@ func NewStore(dir string) *Store { } // Put stores one object from its raw TCE bytes and signature. It returns the -// authoritative object ID. The supplied objectID, if any, must match the -// recomputed one. -func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, error) { +// authoritative object ID and whether this call created the entry (an +// idempotent replay returns created=false). The supplied objectID, if any, +// must match the recomputed one. +func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, bool, error) { if len(tceBytes) > tce.MaxClaimTCE*2 { - return "", fmt.Errorf("server: object too large") + return "", false, fmt.Errorf("server: object too large") } _, obj, err := transport.DecodeObject(tceBytes) if err != nil { - return "", fmt.Errorf("server: rejected: %w", err) + return "", false, fmt.Errorf("server: rejected: %w", err) } id := tce.ComputeID(tceBytes).String() if suppliedID != "" && suppliedID != id { - return "", fmt.Errorf("server: object_id %s does not match recomputed %s", suppliedID, id) + return "", false, fmt.Errorf("server: object_id %s does not match recomputed %s", suppliedID, id) } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.byID[id]; exists { - return id, nil // idempotent + return id, false, nil // idempotent } if claim, ok := obj.(*protocol.Claim); ok { sub := transport.AddrOf(claim.Subject) if len(s.bySubject[sub]) >= maxPerSubject { - return "", fmt.Errorf("server: subject %s quota exceeded", sub) + return "", false, fmt.Errorf("server: subject %s quota exceeded", sub) } } if resp, ok := obj.(*protocol.ApprovalResponse); ok { rh := resp.RequestHash.String() if _, done := s.answeredRequests[rh]; done { - return "", fmt.Errorf("server: request %s already answered", rh) + return "", false, fmt.Errorf("server: request %s already answered", rh) + } + } + if conf, ok := obj.(*protocol.KeyRotationConfirm); ok { + rh := conf.RotationHash.String() + if _, done := s.confirmedRotations[rh]; done { + return "", false, fmt.Errorf("server: rotation %s already confirmed", rh) } } s.addLocked(tceBytes, sig, id, obj) if s.dir != "" { if err := s.writeFile(id, tceBytes, sig); err != nil { - return "", fmt.Errorf("server: persist: %w", err) + return "", false, fmt.Errorf("server: persist: %w", err) } } - return id, nil + return id, true, nil } // 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...), @@ -138,6 +160,8 @@ func (s *Store) addLocked(tceBytes, sig []byte, id string, obj any) { case *protocol.ApprovalResponse: addKey(s.byRequest, o.RequestHash.String(), id) s.answeredRequests[o.RequestHash.String()] = struct{}{} + case *protocol.KeyRotationConfirm: + s.confirmedRotations[o.RotationHash.String()] = struct{}{} } } @@ -250,6 +274,11 @@ func (s *Store) queryIndex(idx map[string]map[string]struct{}, key string) ([]*t } } s.mu.RUnlock() + // Lexicographic order over content addresses is deterministic, which + // makes offset pagination meaningful and `after` cursors possible. + sort.Slice(out, func(i, j int) bool { + return tce.ComputeID(out[i].TCE).String() < tce.ComputeID(out[j].TCE).String() + }) for i, env := range out { d, err := decorate(env) if err != nil { @@ -260,6 +289,31 @@ func (s *Store) queryIndex(idx map[string]map[string]struct{}, key string) ([]*t return out, nil } +// GetMany returns decorated envelopes for the ids that exist, keyed by id. +// Missing ids are simply absent from the result. +func (s *Store) GetMany(ids []string) (map[string]*transport.Envelope, error) { + s.mu.RLock() + found := make([]*transport.Envelope, 0, len(ids)) + want := make(map[string]struct{}, len(ids)) + for _, id := range ids { + want[id] = struct{}{} + if env, ok := s.byID[id]; ok { + found = append(found, env) + } + } + s.mu.RUnlock() + out := make(map[string]*transport.Envelope, len(found)) + for _, env := range found { + id := tce.ComputeID(env.TCE).String() + d, err := decorate(env) + if err != nil { + return nil, err + } + out[id] = d + } + return out, nil +} + // decorate fills the object_id and object view of a stored envelope. func decorate(env *transport.Envelope) (*transport.Envelope, error) { id := tce.ComputeID(env.TCE).String() @@ -284,5 +338,50 @@ 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/server/ws.go b/internal/server/ws.go new file mode 100644 index 0000000..e13a216 --- /dev/null +++ b/internal/server/ws.go @@ -0,0 +1,318 @@ +package server + +// WebSocket streaming. A session holder may subscribe to the same channels +// the REST list endpoints expose — claims by subject, requests by recipient, +// responses by request hash, revocations by claim id — and receive every +// newly stored envelope on those channels as raw {tce, signature}, exactly +// what a GET would have returned. The relay still decides nothing: pushed +// bytes are verified locally like any other envelope (INV-5). +// +// Hygiene: bounded send buffer per subscriber (a slow client is disconnected +// rather than allowed to stall broadcasts), a cap on subscriptions per +// connection, and sessions expire mid-stream just as they would for REST. + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" + + "git.n1ko.dev/Niko/niko_trust/internal/protocol" + "git.n1ko.dev/Niko/niko_trust/internal/transport" +) + +const ( + wsSendBuffer = 64 + wsMaxSubsPerConn = 16 +) + +type wsSubscription struct { + channel string // claims | requests | responses | revocations + key string // subject / recipient / request hash / claim id +} + +type wsSubscriber struct { + subs []wsSubscription + send chan wsEvent + ses session + + // done is closed exactly once, under the hub lock, to signal eviction + // (slow client or disconnect). Closing a signalling channel that nobody + // sends on cannot race the broadcaster, which sends only while holding + // the same lock and skips evicted subscribers. + done chan struct{} + evicted bool + + hub *wsHubState +} + +// evict marks the subscriber dead and wakes its writer. Safe to call twice. +func evict(sub *wsSubscriber) { + h := sub.hub + if h == nil { + return + } + h.mu.Lock() + if !sub.evicted { + sub.evicted = true + close(sub.done) + } + h.mu.Unlock() +} + +// wsEvent is one pushed object. +type wsEvent struct { + Event string `json:"event"` // "object" | "error" | "bye" + Channel string `json:"channel,omitempty"` + Key string `json:"key,omitempty"` + ObjectID string `json:"object_id,omitempty"` + Envelope *wireRequest `json:"envelope,omitempty"` + Message string `json:"message,omitempty"` +} + +type wsHubState struct { + mu sync.Mutex + subs map[*wsSubscriber]struct{} +} + +// register adds a subscriber with its done channel wired to this hub. +func (h *wsHubState) register(sub *wsSubscriber) { + sub.hub = h + h.mu.Lock() + h.subs[sub] = struct{}{} + h.mu.Unlock() +} + +func newWSHub() *wsHubState { return &wsHubState{subs: make(map[*wsSubscriber]struct{})} } + +func (h *wsHubState) remove(sub *wsSubscriber) { + h.mu.Lock() + delete(h.subs, sub) + evicted := !sub.evicted + if evicted { + sub.evicted = true + close(sub.done) + } + h.mu.Unlock() +} + +func (h *wsHubState) broadcast(channel, key, objectID string, tceBytes, sig []byte) { + ev := wsEvent{ + Event: "object", + Channel: channel, + Key: key, + ObjectID: objectID, + Envelope: &wireRequest{TCE: tceBytes, Signature: sig}, + } + h.mu.Lock() + defer h.mu.Unlock() + for sub := range h.subs { + if sub.evicted || !sub.wants(channel, key) { + continue + } + select { + case sub.send <- ev: + default: + // Slow client: drop it rather than stall everyone. The done + // channel wakes the writer; no send ever races a close because + // eviction happens under the same lock as this broadcast. + evictLocked(sub) + } + } +} + +// evictLocked is evict for callers already holding the hub lock. +func evictLocked(sub *wsSubscriber) { + if sub.evicted { + return + } + sub.evicted = true + close(sub.done) +} + +func (sub *wsSubscriber) wants(channel, key string) bool { + for _, s := range sub.subs { + if s.channel == channel && s.key == key { + return true + } + } + return false +} + +// handleWS upgrades an authenticated session into a stream. +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { + ses, ok := s.sessionByToken(r) + if !ok { + writeErr(w, http.StatusUnauthorized, "authentication required") + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + // Browsers cannot set Authorization headers on WebSocket opens; the + // bearer token arrives in the URL. Origin enforcement adds nothing + // on top of token possession, so any origin may connect. + OriginPatterns: []string{"*"}, + }) + if err != nil { + return + } + defer conn.Close(websocket.StatusInternalError, "server shutting down") + + s.metrics.wsConnections.Add(1) + defer s.metrics.wsConnections.Add(-1) + + sub := &wsSubscriber{ + ses: ses, + send: make(chan wsEvent, wsSendBuffer), + done: make(chan struct{}), + } + s.ws.register(sub) + defer s.ws.remove(sub) + + writeFail := make(chan error, 1) + done := make(chan struct{}) + + // Writer loop: forwards events until the connection dies or the session + // expires. Session lifetime applies here exactly as to REST reads. + go func() { + ctx := r.Context() + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case <-sub.done: + conn.Close(websocket.StatusPolicyViolation, "evicted") + return + case <-ticker.C: + if now().After(sub.ses.expiry) { + conn.Close(websocket.StatusNormalClosure, "session expired") + return + } + case ev := <-sub.send: + raw, err := json.Marshal(ev) + if err == nil { + err = conn.Write(ctx, websocket.MessageText, raw) + if err == nil { + s.metrics.inc(&s.metrics.wsMessagesSent) + } + } + if err != nil { + select { + case writeFail <- err: + default: + } + return + } + } + } + }() + + // Reader loop: subscription control frames only. + fail := func(status websocket.StatusCode, msg string) { + ev := wsEvent{Event: "error", Message: msg} + select { + case sub.send <- ev: + default: + } + _ = status + _ = msg + } + for { + typ, data, 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(data, &msg) != nil || msg.Op != "subscribe" && msg.Op != "unsubscribe" { + fail(0, "bad control frame") + continue + } + if !validWSChannel(msg.Channel) { + fail(0, "unknown channel") + continue + } + requiredScope := wsChannelScope(msg.Channel) + if !scopeOK(ses, requiredScope) { + fail(0, "insufficient scope for "+msg.Channel) + continue + } + if len(sub.subs) >= wsMaxSubsPerConn && msg.Op == "subscribe" { + fail(0, "too many subscriptions") + continue + } + + switch msg.Op { + case "subscribe": + if msg.Key == "" { + fail(0, "missing key") + continue + } + sub.subs = append(sub.subs, wsSubscription{channel: msg.Channel, key: msg.Key}) + ev := wsEvent{Event: "subscribed", Channel: msg.Channel, Key: msg.Key} + select { + case sub.send <- ev: + default: + } + case "unsubscribe": + kept := sub.subs[:0] + for _, x := range sub.subs { + if !(x.channel == msg.Channel && (msg.Key == "" || x.key == msg.Key)) { + kept = append(kept, x) + } + } + sub.subs = kept + } + } + + close(done) + conn.Close(websocket.StatusNormalClosure, "") + _ = writeFail +} + +var _ = context.Background + +// validWSChannel names the four channels mirroring the REST list endpoints. +func validWSChannel(ch string) bool { + switch ch { + case "claims", "requests", "responses", "revocations": + return true + } + return false +} + +// wsChannelScope maps a channel onto its REST scope so that a session's +// grants mean identical things over both transports. +func wsChannelScope(ch string) string { + return "read:" + ch +} + +// hubBroadcastObj fans out a freshly stored object to matching subscribers. +// The object is already decoded by the caller. +func (s *Server) hubBroadcastObj(typ string, obj any, tceBytes, sig []byte, objectID string) { + if s.ws == nil || len(s.ws.subs) == 0 { + return + } + switch o := obj.(type) { + case *protocol.Claim: + s.ws.broadcast("claims", transport.AddrOf(o.Subject), objectID, tceBytes, sig) + case *protocol.ApprovalRequest: + s.ws.broadcast("requests", transport.AddrOf(o.Recipient), objectID, tceBytes, sig) + case *protocol.Revocation: + s.ws.broadcast("revocations", o.ClaimID.String(), objectID, tceBytes, sig) + case *protocol.ApprovalResponse: + s.ws.broadcast("responses", o.RequestHash.String(), objectID, tceBytes, sig) + } +} diff --git a/internal/server/ws_test.go b/internal/server/ws_test.go new file mode 100644 index 0000000..7c29454 --- /dev/null +++ b/internal/server/ws_test.go @@ -0,0 +1,348 @@ +package server_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" + "git.n1ko.dev/Niko/niko_trust/internal/protocol" + "git.n1ko.dev/Niko/niko_trust/internal/tce" + "git.n1ko.dev/Niko/niko_trust/internal/transport" +) + +func wsURL(httpURL string) string { + return "ws" + strings.TrimPrefix(httpURL, "http") + "/v1/ws" +} + +func wsCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} + +func dialWS(t *testing.T, url, token string) *websocket.Conn { + t.Helper() + opts := &websocket.DialOptions{} + if token != "" { + opts.HTTPHeader = http.Header{"Authorization": []string{"Bearer " + token}} + } + conn, _, err := websocket.Dial(wsCtx(t), url, opts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(websocket.StatusNormalClosure, "") }) + return conn +} + +func wsWrite(t *testing.T, conn *websocket.Conn, v any) { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if err := conn.Write(wsCtx(t), websocket.MessageText, raw); err != nil { + t.Fatal(err) + } +} + +func wsRead(t *testing.T, conn *websocket.Conn) map[string]any { + t.Helper() + _, raw, err := conn.Read(wsCtx(t)) + if err != nil { + t.Fatalf("read event: %v", err) + } + var ev map[string]any + if json.Unmarshal(raw, &ev) != nil { + t.Fatalf("bad event json: %s", raw) + } + return ev +} + +func wsSubscribe(t *testing.T, conn *websocket.Conn, channel, key string) map[string]any { + t.Helper() + wsWrite(t, conn, map[string]string{"op": "subscribe", "channel": channel, "key": key}) + return wsRead(t, conn) +} + +// wsAssert performs the challenge/assert handshake with an explicit scope. +func wsAssert(t *testing.T, baseURL string, key *signer.Signer, scope string) string { + t.Helper() + resp, err := http.Post(baseURL+"/v1/auth/challenge", "application/json", nil) + if err != nil { + t.Fatal(err) + } + var chRes struct { + Challenge string `json:"challenge"` + } + json.NewDecoder(resp.Body).Decode(&chRes) + resp.Body.Close() + + ch, err := hexDecode(chRes.Challenge) + if err != nil { + t.Fatal(err) + } + a := &protocol.AuthAssertion{ + PubKey: key.Public(), + Challenge: ch, + Scope: scope, + Audience: "trust.n1ko.dev", + CreatedAt: uint64(time.Now().Unix()), + } + b, err := protocol.EncodeAuthAssertion(a) + if err != nil { + t.Fatal(err) + } + r2 := postEnvelope(t, baseURL+"/v1/auth/assert", &transport.Envelope{TCE: b, Signature: key.Sign(b)}) + defer r2.Body.Close() + if r2.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(r2.Body) + t.Fatalf("assert status %d: %s", r2.StatusCode, raw) + } + var out struct { + SessionToken string `json:"session_token"` + } + json.NewDecoder(r2.Body).Decode(&out) + if out.SessionToken == "" { + t.Fatal("no session token") + } + return out.SessionToken +} + +func wsPutClaim(t *testing.T, tsURL string, issuer *signer.Signer, subjectPub ed25519.PublicKey, nonce byte) { + t.Helper() + c := &protocol.Claim{ + Issuer: issuer.Public(), + Subject: subjectPub, + Claims: map[string]tce.Value{"ws.test": tce.Bool(true)}, + CreatedAt: uint64(time.Now().Unix()), + Serial: 1, + Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), + } + b, err := protocol.EncodeClaim(c) + if err != nil { + t.Fatal(err) + } + resp := postEnvelope(t, tsURL+"/v1/objects", &transport.Envelope{TCE: b, Signature: issuer.Sign(b)}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("put status %d: %s", resp.StatusCode, raw) + } +} + +func TestWSRequiresSession(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/v1/ws") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d, want 401", resp.StatusCode) + } +} + +func TestWSRejectsUnknownChannel(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + issuer, _ := signer.Generate() + token := wsAssert(t, ts.URL, issuer, "read") + conn := dialWS(t, wsURL(ts.URL), token) + ev := wsSubscribe(t, conn, "gossip", "whatever") + if !strings.Contains(ev["message"].(string), "unknown channel") { + t.Fatalf("unexpected ack: %v", ev) + } +} + +func TestWSPushesMatchingClaimsOnly(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + issuer, _ := signer.Generate() + subject, _ := signer.Generate() + token := wsAssert(t, ts.URL, issuer, "read") + + conn := dialWS(t, wsURL(ts.URL), token) + ev := wsSubscribe(t, conn, "claims", subject.Identity().String()) + if ev["event"] != "subscribed" { + t.Fatalf("no subscribed ack: %v", ev) + } + + wsPutClaim(t, ts.URL, issuer, subject.Public(), 0x03) + + got := wsRead(t, conn) + if got["event"] != "object" || got["channel"] != "claims" || got["key"] != subject.Identity().String() { + t.Fatalf("unexpected event: %v", got) + } + env, ok := got["envelope"].(map[string]any) + if !ok || env["tce"] == "" || env["signature"] == "" { + t.Fatalf("event missing raw envelope: %v", got) + } + if id, _ := got["object_id"].(string); len(id) != 64 { + t.Fatalf("event missing object id: %v", got) + } + + // A claim about another subject must not arrive before a matching one. + other, _ := signer.Generate() + wsPutClaim(t, ts.URL, issuer, other.Public(), 0x04) + wsPutClaim(t, ts.URL, issuer, subject.Public(), 0x05) + + got2 := wsRead(t, conn) + if got2["key"] != subject.Identity().String() { + t.Fatalf("non-matching claim leaked through: %v", got2) + } +} + +func TestWSScopeGatesChannels(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + issuer, _ := signer.Generate() + token := wsAssert(t, ts.URL, issuer, "read:claims") // no requests scope + + conn := dialWS(t, wsURL(ts.URL), token) + ev := wsSubscribe(t, conn, "requests", issuer.Identity().String()) + msg, _ := ev["message"].(string) + if !strings.Contains(msg, "insufficient scope") { + t.Fatalf("scope not enforced: %v", ev) + } + + // The granted channel still works. + ev2 := wsSubscribe(t, conn, "claims", subjectAddrFor(t)) + if ev2["event"] != "subscribed" { + t.Fatalf("granted channel refused: %v", ev2) + } +} + +func subjectAddrFor(t *testing.T) string { + t.Helper() + s, _ := signer.Generate() + return s.Identity().String() +} + +func hexDecode(s string) ([]byte, error) { + if len(s)%2 != 0 { + s = s[:len(s)-1] + } + out := make([]byte, len(s)/2) + for i := 0; i < len(out); i++ { + hi := hexNibble(s[2*i]) + lo := hexNibble(s[2*i+1]) + out[i] = hi<<4 | lo + } + return out, nil +} + +func hexNibble(c byte) byte { + switch { + case c >= '0' && c <= '9': + return c - '0' + case c >= 'a' && c <= 'f': + return c - 'a' + 10 + case c >= 'A' && c <= 'F': + return c - 'A' + 10 + } + return 0 +} + +func TestBatchFetchAndCursorPagination(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + issuer, _ := signer.Generate() + subject, _ := signer.Generate() + var ids []string + for i := byte(1); i <= 5; i++ { + c := &protocol.Claim{ + Issuer: issuer.Public(), + Subject: subject.Public(), + Claims: map[string]tce.Value{"batch.test": tce.Bool(true)}, + CreatedAt: uint64(time.Now().Unix()), + Serial: 1, + Nonce: bytes.Repeat([]byte{i}, tce.NonceSize), + } + b, err := protocol.EncodeClaim(c) + if err != nil { + t.Fatal(err) + } + resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{TCE: b, Signature: issuer.Sign(b)}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("put %d status %d", i, resp.StatusCode) + } + var out struct { + ObjectID string `json:"object_id"` + } + json.NewDecoder(resp.Body).Decode(&out) + resp.Body.Close() + ids = append(ids, out.ObjectID) + } + + // Batch fetch returns exactly the requested objects. + q := ids[0] + "," + ids[2] + ",ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + resp, err := http.Get(ts.URL + "/v1/objects?ids=" + q) + if err != nil { + t.Fatal(err) + } + var batch struct { + Objects map[string]json.RawMessage `json:"objects"` + } + json.NewDecoder(resp.Body).Decode(&batch) + resp.Body.Close() + if len(batch.Objects) != 2 { + t.Fatalf("batch returned %d objects, want 2", len(batch.Objects)) + } + if _, ok := batch.Objects[ids[0]]; !ok { + t.Fatal("batch missing first id") + } + if _, ok := batch.Objects[ids[2]]; !ok { + t.Fatal("batch missing third id") + } + + // Cursor pagination walks deterministically. + token := wsAssert(t, ts.URL, issuer, "read") + listPage := func(after string) []string { + url := ts.URL + "/v1/claims?subject=" + subject.Identity().String() + + "&limit=2&token=" + token + if after != "" { + url += "&after=" + after + } + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Claims []struct { + ObjectID string `json:"object_id"` + } `json:"claims"` + } + json.NewDecoder(resp.Body).Decode(&out) + var got []string + for _, c := range out.Claims { + got = append(got, c.ObjectID) + } + return got + } + + page1 := listPage("") + if len(page1) != 2 { + t.Fatalf("page1 = %d entries", len(page1)) + } + page2 := listPage(page1[len(page1)-1]) + if len(page2) == 0 || page2[0] <= page1[len(page1)-1] { + t.Fatalf("cursor did not advance: page1 last %s, page2 %v", page1[len(page1)-1], page2) + } +} diff --git a/internal/smt/fuzz_test.go b/internal/smt/fuzz_test.go new file mode 100644 index 0000000..8b2d98a --- /dev/null +++ b/internal/smt/fuzz_test.go @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000..2befe4c --- /dev/null +++ b/internal/smt/proofs.go @@ -0,0 +1,385 @@ +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 new file mode 100644 index 0000000..7fa5351 --- /dev/null +++ b/internal/smt/property_test.go @@ -0,0 +1,351 @@ +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 new file mode 100644 index 0000000..0687620 --- /dev/null +++ b/internal/smt/smt.go @@ -0,0 +1,271 @@ +// 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 new file mode 100644 index 0000000..d1234f3 --- /dev/null +++ b/internal/smt/testdata/fuzz/FuzzVerifyProofsIsTotal/d49a0e270743a8e3 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("\x03\x00\x0e00") diff --git a/internal/tce/id_test.go b/internal/tce/id_test.go index 0e6e652..cc1d1f3 100644 --- a/internal/tce/id_test.go +++ b/internal/tce/id_test.go @@ -49,10 +49,10 @@ func TestParseID(t *testing.T) { } bad := []string{ - good[:63], // too short - good + "0", // too long - good[:63] + "G", // non-hex - good[:63] + "A", // uppercase rejected + good[:63], // too short + good + "0", // too long + good[:63] + "G", // non-hex + good[:63] + "A", // uppercase rejected } for _, b := range bad { if _, err := tce.ParseID(b); err == nil { diff --git a/internal/tce/invariants_test.go b/internal/tce/invariants_test.go index 1c50811..984e04a 100644 --- a/internal/tce/invariants_test.go +++ b/internal/tce/invariants_test.go @@ -87,15 +87,15 @@ func TestNoSigningInCodec(t *testing.T) { func TestAllowedImports(t *testing.T) { allowed := map[string]bool{ - "crypto/sha256": true, - "crypto/subtle": true, - "encoding/hex": true, - "errors": true, - "fmt": true, - "math/big": true, - "sort": true, - "unicode/utf8": true, - "strconv": true, + "crypto/sha256": true, + "crypto/subtle": true, + "encoding/hex": true, + "errors": true, + "fmt": true, + "math/big": true, + "sort": true, + "unicode/utf8": true, + "strconv": true, } for _, f := range scanPackage(t, ".") { for _, imp := range importsOf(t, f) { diff --git a/internal/tce/number_test.go b/internal/tce/number_test.go index 3a1f0f1..f75af97 100644 --- a/internal/tce/number_test.go +++ b/internal/tce/number_test.go @@ -20,7 +20,7 @@ func TestCanonicalNumber(t *testing.T) { {"1e0", "1", false}, {"1E0", "1", false}, {"1.00", "1", false}, - {"001", "", true}, // leading zero + {"001", "", true}, // leading zero {"0", "0", false}, {"0.0", "0", false}, {"-0", "0", false}, @@ -31,8 +31,8 @@ func TestCanonicalNumber(t *testing.T) { // Fractions (no trailing zeros, no leading zero in int part). {"0.1", "0.1", false}, {"0.10", "0.1", false}, - {".5", "", true}, // missing integer part - {"1.", "", true}, // missing fraction digits + {".5", "", true}, // missing integer part + {"1.", "", true}, // missing fraction digits {"1.2.3", "", true}, // Exponents move the point and strip factors of ten. @@ -56,8 +56,8 @@ func TestCanonicalNumber(t *testing.T) { // Grammar rejections. {"", "", true}, {"abc", "", true}, - {"+1", "", true}, // sign must be minus - {" 1", "", true}, // no whitespace + {"+1", "", true}, // sign must be minus + {" 1", "", true}, // no whitespace {"1 ", "", true}, {"0x1", "", true}, {"1_000", "", true}, @@ -66,7 +66,7 @@ func TestCanonicalNumber(t *testing.T) { // Range: large integers and deep fractions are bounded. {"123456789012345678901234567890", "123456789012345678901234567890", false}, - {"1e40", "", true}, // far beyond max integer digits + {"1e40", "", true}, // far beyond max integer digits } for _, c := range cases { got, err := tce.CanonicalNumber(c.in) diff --git a/internal/tce/tce.go b/internal/tce/tce.go index d7a6b08..7ba9338 100644 --- a/internal/tce/tce.go +++ b/internal/tce/tce.go @@ -56,6 +56,9 @@ const ( TagApprovalRequest ObjectTag = 0x04 TagApprovalResponse ObjectTag = 0x05 TagAuthAssertion ObjectTag = 0x06 + TagDelegation ObjectTag = 0x07 + TagKeyRotation ObjectTag = 0x08 + TagKeyRotationConf ObjectTag = 0x09 ) // String renders a tag for diagnostics. @@ -73,6 +76,12 @@ func (t ObjectTag) String() string { return "approval_response" case TagAuthAssertion: return "auth_assertion" + case TagDelegation: + return "delegation" + case TagKeyRotation: + return "key_rotation_request" + case TagKeyRotationConf: + return "key_rotation_confirm" default: return fmt.Sprintf("unknown(0x%02x)", byte(t)) } @@ -82,7 +91,8 @@ func (t ObjectTag) String() string { func knownTag(t ObjectTag) bool { switch t { case TagIdentity, TagClaim, TagRevocation, - TagApprovalRequest, TagApprovalResponse, TagAuthAssertion: + TagApprovalRequest, TagApprovalResponse, TagAuthAssertion, TagDelegation, + TagKeyRotation, TagKeyRotationConf: return true default: return false @@ -140,6 +150,8 @@ const ( MaxIdentityTCE = 1024 MaxClaimTCE = 4096 + MaxDelegTCE = 2048 + MaxKeyRotTCE = 1024 MaxRevocTCE = 1024 MaxRequestTCE = 8192 MaxResponseTCE = 1024 diff --git a/internal/transport/view.go b/internal/transport/view.go index 3e25635..31ba847 100644 --- a/internal/transport/view.go +++ b/internal/transport/view.go @@ -55,6 +55,12 @@ func objectTypeOf(obj any) string { return "response" case *protocol.AuthAssertion: return "auth" + case *protocol.DelegationClaim: + return "delegation" + case *protocol.KeyRotationRequest: + return "key_rotation_request" + case *protocol.KeyRotationConfirm: + return "key_rotation_confirm" } return "" } @@ -89,6 +95,15 @@ func DecodeObject(b []byte) (string, any, error) { case tce.TagAuthAssertion: o, e := protocol.DecodeAuthAssertion(b) return "auth", o, e + case tce.TagDelegation: + o, e := protocol.DecodeDelegationClaim(b) + return "delegation", o, e + case tce.TagKeyRotation: + o, e := protocol.DecodeKeyRotationRequest(b) + return "key_rotation_request", o, e + case tce.TagKeyRotationConf: + o, e := protocol.DecodeKeyRotationConfirm(b) + return "key_rotation_confirm", o, e default: return "", nil, tce.ErrObjectTag } @@ -156,24 +171,58 @@ func BuildView(b []byte) (objectType string, view json.RawMessage, err error) { } case *protocol.ApprovalResponse: v = map[string]any{ - "type": "response", - "version": 1, + "type": "response", + "version": 1, "request_hash": o.RequestHash.String(), - "responder": addrOf(o.Responder), - "decision": o.Decision.String(), - "created_at": o.CreatedAt, - "nonce": hexStr(o.Nonce), + "responder": addrOf(o.Responder), + "decision": o.Decision.String(), + "created_at": o.CreatedAt, + "nonce": hexStr(o.Nonce), } case *protocol.AuthAssertion: v = map[string]any{ - "type": "auth", - "version": 1, - "identity": addrOf(o.PubKey), - "challenge": hexStr(o.Challenge), - "scope": o.Scope, - "audience": o.Audience, + "type": "auth", + "version": 1, + "identity": addrOf(o.PubKey), + "challenge": hexStr(o.Challenge), + "scope": o.Scope, + "audience": o.Audience, "created_at": o.CreatedAt, } + case *protocol.DelegationClaim: + predicates := make(map[string]any, len(o.Predicates)) + for k, val := range o.Predicates { + predicates[k] = valueView(val) + } + v = map[string]any{ + "type": "delegation", + "version": 1, + "granter": addrOf(o.Granter), + "grantee": addrOf(o.Grantee), + "predicates": predicates, + "max_depth": o.MaxDepth, + "created_at": o.CreatedAt, + "expires_at": o.ExpiresAt, + "serial": o.Serial, + "nonce": hexStr(o.Nonce), + } + case *protocol.KeyRotationRequest: + v = map[string]any{ + "type": "key_rotation_request", + "version": 1, + "successor": addrOf(o.Successor), + "predecessor": addrOf(o.Predecessor), + "created_at": o.CreatedAt, + "expires_at": o.ExpiresAt, + } + case *protocol.KeyRotationConfirm: + v = map[string]any{ + "type": "key_rotation_confirm", + "version": 1, + "rotation_hash": o.RotationHash.String(), + "created_at": o.CreatedAt, + "nonce": hexStr(o.Nonce), + } } raw, err := json.Marshal(v) if err != nil { diff --git a/internal/verify/delegation_test.go b/internal/verify/delegation_test.go new file mode 100644 index 0000000..eca7ae4 --- /dev/null +++ b/internal/verify/delegation_test.go @@ -0,0 +1,248 @@ +package verify_test + +import ( + "bytes" + "testing" + + "git.n1ko.dev/Niko/niko_trust/internal/address" + + "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" + "git.n1ko.dev/Niko/niko_trust/internal/protocol" + "git.n1ko.dev/Niko/niko_trust/internal/tce" + "git.n1ko.dev/Niko/niko_trust/internal/verify" +) + +// delegationFixture builds a graph plus signers for chain scenarios. +type delegationFixture struct { + root *signer.Signer + mid *signer.Signer + issuer *signer.Signer + subj *signer.Signer + g *verify.Graph +} + +func newDelegationFixture(t *testing.T) *delegationFixture { + t.Helper() + f := &delegationFixture{ + root: mustSigner(t), + mid: mustSigner(t), + issuer: mustSigner(t), + subj: mustSigner(t), + g: verify.NewGraph(), + } + return f +} + +func mustSigner(t *testing.T) *signer.Signer { + t.Helper() + s, err := signer.Generate() + if err != nil { + t.Fatal(err) + } + return s +} + +func (f *delegationFixture) addDelegation(t *testing.T, granter, grantee *signer.Signer, + predicate string, maxDepth uint64, createdAt uint64, expiresAt uint64, serial uint64, nonce byte) { + + t.Helper() + d := &protocol.DelegationClaim{ + Granter: granter.Public(), + Grantee: grantee.Public(), + Predicates: map[string]tce.Value{predicate: tce.Bool(true)}, + MaxDepth: maxDepth, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + Serial: serial, + Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), + } + b, err := protocol.EncodeDelegationClaim(d) + if err != nil { + t.Fatal(err) + } + if err := f.g.Add(env(t, b, granter.Sign(b))); err != nil { + t.Fatal(err) + } +} + +func (f *delegationFixture) revokeDelegation(t *testing.T, granter *signer.Signer, + cp *protocol.DelegationClaim, nonce byte) *protocol.DelegationClaim { + + t.Helper() + id := tce.ComputeID(mustTCEOf(t, cp)) + rev := &protocol.Revocation{ + Issuer: granter.Public(), + ClaimID: id, + CreatedAt: base + 50, + Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), + } + b, err := protocol.EncodeRevocation(rev) + if err != nil { + t.Fatal(err) + } + if err := f.g.Add(env(t, b, granter.Sign(b))); err != nil { + t.Fatal(err) + } + return cp +} + +func mustTCEOf(t *testing.T, o interface{ TCE() []byte }) []byte { + t.Helper() + b := o.TCE() + if b == nil { + t.Fatal("object has no retained canonical bytes; was it added through Add?") + } + return b +} + +func (f *delegationFixture) addClaim(t *testing.T, issuer *signer.Signer, predicate string) { + t.Helper() + c := &protocol.Claim{ + Issuer: issuer.Public(), + Subject: f.subj.Public(), + Claims: map[string]tce.Value{predicate: tce.Bool(true)}, + CreatedAt: base - 100, + Serial: 1, + Nonce: bytes.Repeat([]byte{0x21}, tce.NonceSize), + } + cb, err := protocol.EncodeClaim(c) + if err != nil { + t.Fatal(err) + } + if err := f.g.Add(env(t, cb, issuer.Sign(cb))); err != nil { + t.Fatal(err) + } +} + +func (f *delegationFixture) evaluate(predicate string, now uint64) verify.Result { + return f.g.Evaluate(verify.Policy{ + Subject: f.subj.Address(), + Predicate: predicate, + TrustedIssuers: []address.Address{f.root.Identity().Address()}, + Now: now, + }) +} + +func TestDelegationChainDepthTwo(t *testing.T) { + f := newDelegationFixture(t) + + // root grants mid (depth budget 2), mid grants issuer (budget 1). + f.addDelegation(t, f.root, f.mid, "mod", 2, base-200, 0, 1, 0x31) + f.addDelegation(t, f.mid, f.issuer, "mod", 1, base-150, 0, 1, 0x32) + f.addClaim(t, f.issuer, "mod") + + res := f.evaluate("mod", base+10) + if !res.Trusted { + t.Fatalf("chain rejected: %q", res.Reason) + } + if res.Issuer.String() != f.issuer.Address().String() { + t.Fatalf("issuer %s", res.Issuer) + } + if len(res.Chain) != 3 || + res.Chain[0].String() != f.issuer.Address().String() || + res.Chain[1].String() != f.mid.Address().String() || + res.Chain[2].String() != f.root.Address().String() { + t.Fatalf("chain = %v", res.Chain) + } +} + +func TestDelegationPredicateMismatch(t *testing.T) { + f := newDelegationFixture(t) + f.addDelegation(t, f.root, f.issuer, "other.predicate", 0, base-200, 0, 1, 0x33) + f.addClaim(t, f.issuer, "mod") + + if res := f.evaluate("mod", base+10); res.Trusted { + t.Fatal("claim accepted through a grant covering a different predicate") + } +} + +func TestDelegationExpiredLinkFails(t *testing.T) { + f := newDelegationFixture(t) + // The only link expired before the evaluation instant. + f.addDelegation(t, f.root, f.issuer, "mod", 0, base-400, base-300, 1, 0x34) + f.addClaim(t, f.issuer, "mod") + + if res := f.evaluate("mod", base+10); res.Trusted { + t.Fatal("expired grant accepted") + } +} + +func TestDelegationRevokedLinkFails(t *testing.T) { + f := newDelegationFixture(t) + f.addDelegation(t, f.root, f.issuer, "mod", 0, base-200, 0, 1, 0x35) + + // Rebuild the same grant deterministically to obtain its ID for the + // revocation, then revoke it. + d := &protocol.DelegationClaim{ + Granter: f.root.Public(), + Grantee: f.issuer.Public(), + Predicates: map[string]tce.Value{"mod": tce.Bool(true)}, + MaxDepth: 0, + CreatedAt: base - 200, + Serial: 1, + Nonce: bytes.Repeat([]byte{0x35}, tce.NonceSize), + } + id := computeIDOf(t, d) + rev := &protocol.Revocation{ + Issuer: f.root.Public(), + ClaimID: id, + Reason: "grant withdrawn", + CreatedAt: base - 50, + Nonce: bytes.Repeat([]byte{0x36}, tce.NonceSize), + } + rb, err := protocol.EncodeRevocation(rev) + if err != nil { + t.Fatal(err) + } + if err := f.g.Add(env(t, rb, f.root.Sign(rb))); err != nil { + t.Fatal(err) + } + f.addClaim(t, f.issuer, "mod") + + if res := f.evaluate("mod", base+10); res.Trusted { + t.Fatal("revoked grant accepted") + } +} + +func TestDelegationMaxDepthBudgetRespected(t *testing.T) { + f := newDelegationFixture(t) + // The middle link forbids any re-delegation below it. + f.addDelegation(t, f.root, f.mid, "mod", 2, base-200, 0, 1, 0x37) + f.addDelegation(t, f.mid, f.issuer, "mod", 0, base-150, 0, 1, 0x38) + f.addClaim(t, f.issuer, "mod") + + if res := f.evaluate("mod", base+10); res.Trusted { + t.Fatal("two-hop chain accepted although the last link has budget 0") + } +} + +func TestDelegationCycleTerminates(t *testing.T) { + f := newDelegationFixture(t) + // a ↔ b mutual grants: bounded by MaxDepth, never loops forever. + a, _ := signer.Generate() + b, _ := signer.Generate() + f.addDelegation(t, a, b, "mod", 9, base-200, 0, 1, 0x39) + f.addDelegation(t, b, a, "mod", 9, base-200, 0, 1, 0x3A) + f.addClaim(t, a, "mod") // claim issuer is not the trusted root + + if res := f.evaluate("mod", base+10); res.Trusted { + t.Fatal("cycle satisfied the policy without reaching the trusted root") + } +} + +func TestDirectIssuerStillTrustedWithFilterSet(t *testing.T) { + f := newDelegationFixture(t) + f.addClaim(t, f.root, "mod") + if res := f.evaluate("mod", base+10); !res.Trusted || len(res.Chain) != 0 { + t.Fatalf("direct issuance broken: %+v", res) + } +} + +func computeIDOf(t *testing.T, d *protocol.DelegationClaim) tce.ID { + t.Helper() + b, err := protocol.EncodeDelegationClaim(d) + if err != nil { + t.Fatal(err) + } + return tce.ComputeID(b) +} diff --git a/internal/verify/rotation_test.go b/internal/verify/rotation_test.go new file mode 100644 index 0000000..10d76a2 --- /dev/null +++ b/internal/verify/rotation_test.go @@ -0,0 +1,188 @@ +package verify_test + +import ( + "bytes" + "testing" + + "git.n1ko.dev/Niko/niko_trust/internal/address" + "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" + "git.n1ko.dev/Niko/niko_trust/internal/protocol" + "git.n1ko.dev/Niko/niko_trust/internal/tce" + "git.n1ko.dev/Niko/niko_trust/internal/verify" +) + +// rotationFixture stores both halves of a rotation link. +type rotationFixture struct { + oldKey *signer.Signer + newKey *signer.Signer + subj *signer.Signer + g *verify.Graph +} + +func newRotationFixture(t *testing.T) *rotationFixture { + t.Helper() + return &rotationFixture{ + oldKey: mustSigner(t), + newKey: mustSigner(t), + subj: mustSigner(t), + g: verify.NewGraph(), + } +} + +func (f *rotationFixture) addRotation(t *testing.T, old, new *signer.Signer, + createdAt uint64, lifetime int64, confAt uint64, nonce byte) { + + t.Helper() + req := &protocol.KeyRotationRequest{ + Successor: new.Public(), + Predecessor: old.Public(), + CreatedAt: createdAt, + ExpiresAt: createdAt + uint64(lifetime), + } + rb, err := protocol.EncodeKeyRotationRequest(req) + if err != nil { + t.Fatal(err) + } + rsig := new.Sign(rb) + if err := f.g.Add(env(t, rb, rsig)); err != nil { + t.Fatal(err) + } + + conf := &protocol.KeyRotationConfirm{ + RotationHash: tce.ComputeID(rb), + CreatedAt: confAt, + Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), + } + cb, err := protocol.EncodeKeyRotationConfirm(conf) + if err != nil { + t.Fatal(err) + } + if err := f.g.Add(env(t, cb, old.Sign(cb))); err != nil { + t.Fatal(err) + } +} + +func (f *rotationFixture) addClaimBy(t *testing.T, key *signer.Signer) { + c := &protocol.Claim{ + Issuer: key.Public(), + Subject: f.subj.Public(), + Claims: map[string]tce.Value{"mod": tce.Bool(true)}, + CreatedAt: base - 100, + Serial: 1, + Nonce: bytes.Repeat([]byte{0x41}, tce.NonceSize), + } + cb, _ := protocol.EncodeClaim(c) + _ = f.g.Add(env(t, cb, key.Sign(cb))) +} + +func (f *rotationFixture) evaluate(now uint64, maxAge uint64) verify.Result { + rootAddr := f.oldKey.Identity().Address() + return f.g.Evaluate(verify.Policy{ + Subject: f.subj.Address(), + Predicate: "mod", + TrustedIssuers: []address.Address{rootAddr}, + RotationMaxAge: maxAge, + Now: now, + }) +} + +func TestRotationSingleHop(t *testing.T) { + f := newRotationFixture(t) + f.addRotation(t, f.oldKey, f.newKey, base-30, 60, base-20, 0x51) + f.addClaimBy(t, f.newKey) + + res := f.evaluate(base+10, 0) + if !res.Trusted { + t.Fatalf("rotated issuer rejected: %q", res.Reason) + } + if len(res.RotationChain) != 2 || + res.RotationChain[0].String() != f.newKey.Identity().Address().String() || + res.RotationChain[1].String() != f.oldKey.Identity().Address().String() { + t.Fatalf("rotation chain = %v", res.RotationChain) + } +} + +func TestRotationTwoHops(t *testing.T) { + f := newRotationFixture(t) + mid := mustSigner(t) + + // root(old) → mid → new + f.addRotation(t, f.oldKey, mid, base-60, 60, base-50, 0x52) + f.addRotation(t, mid, f.newKey, base-40, 60, base-30, 0x53) + f.addClaimBy(t, f.newKey) + + res := f.evaluate(base+10, 0) + if !res.Trusted || len(res.RotationChain) != 3 { + t.Fatalf("two-hop rotation failed: %+v", res) + } +} + +func TestRotationWithoutConfirmFails(t *testing.T) { + f := newRotationFixture(t) + req := &protocol.KeyRotationRequest{ + Successor: f.newKey.Public(), + Predecessor: f.oldKey.Public(), + CreatedAt: base - 30, + ExpiresAt: base + 30, + } + rb, _ := protocol.EncodeKeyRotationRequest(req) + _ = f.g.Add(env(t, rb, f.newKey.Sign(rb))) + f.addClaimBy(t, f.newKey) + + if res := f.evaluate(base+10, 0); res.Trusted { + t.Fatal("unconfirmed succession accepted") + } +} + +func TestRotationWrongConfirmerFails(t *testing.T) { + f := newRotationFixture(t) + impostor := mustSigner(t) + + req := &protocol.KeyRotationRequest{ + Successor: f.newKey.Public(), + Predecessor: f.oldKey.Public(), + CreatedAt: base - 30, + ExpiresAt: base + 30, + } + rb, _ := protocol.EncodeKeyRotationRequest(req) + _ = f.g.Add(env(t, rb, f.newKey.Sign(rb))) + + conf := &protocol.KeyRotationConfirm{ + RotationHash: tce.ComputeID(rb), + CreatedAt: base - 10, + Nonce: bytes.Repeat([]byte{0x54}, tce.NonceSize), + } + cb, _ := protocol.EncodeKeyRotationConfirm(conf) + _ = f.g.Add(env(t, cb, impostor.Sign(cb))) // not the predecessor + + f.addClaimBy(t, f.newKey) + if res := f.evaluate(base+10, 0); res.Trusted { + t.Fatal("consent from a stranger accepted") + } +} + +func TestRotationStaleLinkFailsWithMaxAge(t *testing.T) { + f := newRotationFixture(t) + f.addRotation(t, f.oldKey, f.newKey, base-500, 60, base-490, 0x55) + f.addClaimBy(t, f.newKey) + + // Fresh enough without a limit. + if res := f.evaluate(base+10, 0); !res.Trusted { + t.Fatalf("no-limit policy rejected fresh history: %q", res.Reason) + } + // Stale once freshness is demanded: confirm is ~500s old, limit 300s. + if res := f.evaluate(base+10, 300); res.Trusted { + t.Fatal("stale rotation accepted under RotationMaxAge") + } +} + +func TestRotationWindowEnforced(t *testing.T) { + f := newRotationFixture(t) + // Confirm dated after the request expired by more than the skew. + f.addRotation(t, f.oldKey, f.newKey, base-100, 10, base+200, 0x56) + f.addClaimBy(t, f.newKey) + + if res := f.evaluate(base+250, 0); res.Trusted { + t.Fatal("confirm outside the request window accepted") + } +} diff --git a/internal/verify/verify.go b/internal/verify/verify.go index 087b7c1..ba3dd2f 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -11,6 +11,7 @@ package verify import ( "crypto/subtle" "fmt" + "sort" "sync" "git.n1ko.dev/Niko/niko_trust/internal/address" @@ -24,20 +25,38 @@ import ( type Graph struct { mu sync.Mutex - claims map[string]*protocol.Claim // claim object id -> claim - claimEnv map[string]*transport.Envelope // claim object id -> envelope - revocations map[string][]*protocol.Revocation // target claim id -> revocations + claims map[string]*protocol.Claim // claim object id -> claim + claimEnv map[string]*transport.Envelope // claim object id -> envelope + delegations map[string]*protocol.DelegationClaim // delegation id -> grant + delegEnv map[string]*transport.Envelope // delegation id -> envelope + rotRequests map[string]*protocol.KeyRotationRequest // request id -> request + rotConfirms map[string][]*rotationConfirm // request id -> confirms + rotEnv map[string]*transport.Envelope // request/confirm id -> envelope + revocations map[string][]*protocol.Revocation // target claim id -> revocations requests map[string]*protocol.ApprovalRequest requestEnv map[string]*transport.Envelope responses map[string]*protocol.ApprovalResponse responseEnv map[string]*transport.Envelope } +// rotationConfirm pairs a stored confirm with the content id it was filed +// under, so ties between competing consents resolve deterministically. +type rotationConfirm struct { + id string + c *protocol.KeyRotationConfirm + env *transport.Envelope +} + // NewGraph returns an empty graph. func NewGraph() *Graph { return &Graph{ claims: make(map[string]*protocol.Claim), claimEnv: make(map[string]*transport.Envelope), + delegations: make(map[string]*protocol.DelegationClaim), + delegEnv: make(map[string]*transport.Envelope), + rotRequests: make(map[string]*protocol.KeyRotationRequest), + rotConfirms: make(map[string][]*rotationConfirm), + rotEnv: make(map[string]*transport.Envelope), revocations: make(map[string][]*protocol.Revocation), requests: make(map[string]*protocol.ApprovalRequest), requestEnv: make(map[string]*transport.Envelope), @@ -64,6 +83,16 @@ func (g *Graph) Add(env *transport.Envelope) error { case *protocol.Claim: g.claims[id] = o g.claimEnv[id] = env + case *protocol.DelegationClaim: + g.delegations[id] = o + g.delegEnv[id] = env + case *protocol.KeyRotationRequest: + g.rotRequests[id] = o + g.rotEnv[id] = env + case *protocol.KeyRotationConfirm: + g.rotConfirms[o.RotationHash.String()] = append( + g.rotConfirms[o.RotationHash.String()], + &rotationConfirm{id: id, c: o, env: env}) case *protocol.Revocation: g.revocations[o.ClaimID.String()] = append(g.revocations[o.ClaimID.String()], o) case *protocol.ApprovalRequest: @@ -89,19 +118,41 @@ type Policy struct { // Threshold is how many distinct approvers must have allowed (k-of-n). // Zero means "any one" (k = 1). Threshold int + // TrustedIssuers restricts which issuers may satisfy the policy. Empty + // means "any issuer the caller fed into the graph", preserving the + // original model where anchoring is fully out of band. A non-empty set + // is a pure filter: claims from other issuers are ignored, never + // treated as negative evidence. + TrustedIssuers []address.Address + // MaxDepth caps delegation chain hops when TrustedIssuers is set. Zero + // means the built-in default (3). It also bounds key-rotation chains. + MaxDepth int + // RotationMaxAge is how many seconds old a key-rotation confirm may be + // at the evaluation instant and still count. Zero disables the check: + // a confirmed rotation never goes stale. The bound exists because a + // stolen predecessor key could otherwise rotate silently forever. + RotationMaxAge uint64 // Now is the evaluation time. Now uint64 } // Result is the evaluator's decision. type Result struct { - Trusted bool - Claim *protocol.Claim - Issuer address.Address - ApprovedBy address.Address // first approver that allowed (if any) - ApprovedByAll []address.Address // all approvers that allowed - Revoked bool - Reason string + Trusted bool + Claim *protocol.Claim + Issuer address.Address + ApprovedBy address.Address // first approver that allowed (if any) + ApprovedByAll []address.Address // all approvers that allowed + Revoked bool + // Chain is the delegation path issuer → … → trusted root when the + // claim's authority came from one; nil for direct issuance. It is + // audit evidence, not an authorization statement by itself. + Chain []address.Address + // RotationChain is the key-succession path issuer → … → root when the + // claim's issuer reached a trusted root through confirmed rotations. + // Also audit evidence only. + RotationChain []address.Address + Reason string } // Evaluate decides whether, under the policy, the subject holds the predicate. @@ -109,8 +160,15 @@ func (g *Graph) Evaluate(p Policy) Result { g.mu.Lock() defer g.mu.Unlock() - var best *protocol.Claim - var bestID string + // Collect every structurally valid candidate, then decide trust per + // candidate in a deterministic order: highest serial first, ties broken + // by content ID. Determinism matters now that acceptance may require a + // delegation walk — map iteration order must never leak into decisions. + type candidate struct { + id string + c *protocol.Claim + } + var candidates []candidate for id, c := range g.claims { if transport.AddrOf(c.Subject) != p.Subject.String() { continue @@ -122,35 +180,178 @@ func (g *Graph) Evaluate(p Policy) Result { if protocol.ValidateCurrent(c.CreatedAt, c.ExpiresAt, p.Now) != nil { continue } - if best == nil || c.Serial > best.Serial { - best = c - bestID = id + candidates = append(candidates, candidate{id, c}) + } + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + if a.c.Serial != b.c.Serial { + return a.c.Serial > b.c.Serial } - } - if best == nil { - return Result{Reason: "no active claim for predicate"} - } + return a.id < b.id + }) - // Revocation: a verified revocation must be signed by the claim's issuer and - // target this exact claim. - for _, rev := range g.revocations[bestID] { - if protocol.VerifyRevocationOf(rev, best) == nil { - return Result{Claim: best, Issuer: mustAddr(best.Issuer), Revoked: true, Reason: "revoked: " + rev.Reason} + for _, cand := range candidates { + issuer := mustAddr(cand.c.Issuer) + + // Trust anchoring: an empty filter keeps the original model (the + // caller fed only objects it cares about). A non-empty filter is + // satisfied either directly or through a delegation chain from a + // trusted root down to the claim's actual issuer. + var chain, rotChain []address.Address + if len(p.TrustedIssuers) > 0 && !addrInAddr(issuer, p.TrustedIssuers) { + resolved, ok := g.resolveDelegationChain(cand.c.Issuer, p) + if !ok { + rotChain, ok = g.resolveRotationChain(cand.c.Issuer, p) + } + if !ok { + continue // this issuer cannot speak; try the next claim + } + chain = resolved } - } - issuer := mustAddr(best.Issuer) - - // Optional approval requirement. - if len(p.Approvers) > 0 { - first, all, ok := g.findApproval(best, bestID, p) - if !ok { - return Result{Claim: best, Issuer: issuer, Reason: "no valid approval from required approvers"} + // Revocation: a verified revocation must be signed by the claim's + // issuer and target this exact claim. + for _, rev := range g.revocations[cand.id] { + if protocol.VerifyRevocationOf(rev, cand.c) == nil { + return Result{Claim: cand.c, Issuer: issuer, Chain: chain, + RotationChain: rotChain, + Revoked: true, Reason: "revoked: " + rev.Reason} + } } - return Result{Trusted: true, Claim: best, Issuer: issuer, ApprovedBy: first, ApprovedByAll: all} + + // Optional approval requirement. + if len(p.Approvers) > 0 { + first, all, ok := g.findApproval(cand.c, cand.id, p) + if !ok { + return Result{Claim: cand.c, Issuer: issuer, Chain: chain, + RotationChain: rotChain, + Reason: "no valid approval from required approvers"} + } + return Result{Trusted: true, Claim: cand.c, Issuer: issuer, Chain: chain, + RotationChain: rotChain, ApprovedBy: first, ApprovedByAll: all} + } + + return Result{Trusted: true, Claim: cand.c, Issuer: issuer, Chain: chain, + RotationChain: rotChain} + } + return Result{Reason: "no active claim for predicate"} +} + +// defaultMaxDepth bounds delegation chains when the policy does not say. +const defaultMaxDepth = 3 + +// resolveDelegationChain searches for a path of active, unrevoked, covering +// delegations from the claim's issuer up to any trusted root, honouring each +// link's own re-delegation budget. It returns the chain ordered issuer → … +// → root (inclusive of both ends). +func (g *Graph) resolveDelegationChain(issuerPub []byte, p Policy) ([]address.Address, bool) { + maxDepth := p.MaxDepth + if maxDepth <= 0 { + maxDepth = defaultMaxDepth } - return Result{Trusted: true, Claim: best, Issuer: issuer} + origin := append([]byte(nil), issuerPub...) + current := append([]byte(nil), issuerPub...) + chain := []address.Address{mustAddr(origin)} + + for hop := 1; hop <= maxDepth; hop++ { + // Pick the strongest grant to current: highest serial, then lowest + // content id, so the outcome never depends on map order. + bestID := "" + var best *protocol.DelegationClaim + for id, d := range g.delegations { + if subtle.ConstantTimeCompare(d.Grantee, current) != 1 { + continue + } + if !d.Covers(p.Predicate) { + continue + } + if protocol.DelegationStatusAt(d, p.Now) != protocol.StatusActive { + continue + } + // The granter revokes its own grant by object id. + withdrawn := false + for _, rev := range g.revocations[id] { + if protocol.VerifyRevocationOfDelegation(rev, d) == nil { + withdrawn = true + break + } + } + if withdrawn { + continue + } + if best == nil || d.Serial > best.Serial || + (d.Serial == best.Serial && id < bestID) { + best, bestID = d, id + } + } + if best == nil { + return nil, false + } + root := mustAddr(best.Granter) + chain = append(chain, root) + if addrInAddr(root, p.TrustedIssuers) { + // Every link must have allowed the hops that sit below it: + // link i of k needs MaxDepth >= k-i. + k := len(chain) - 1 + links := g.collectLinks(origin, p, k) + if len(links) != k { + return nil, false + } + // Link i has k-1-i hops strictly below it; each must fit in + // that link's own budget. + for i, d := range links { + if uint64(k-1-i) > d.MaxDepth { + return nil, false + } + } + return chain, true + } + current = best.Granter + } + return nil, false +} + +// collectLinks replays the chosen chain deterministically, returning the +// delegation objects in issuer→root order for depth-budget checking. +func (g *Graph) collectLinks(issuerPub []byte, p Policy, maxHops int) []*protocol.DelegationClaim { + current := append([]byte(nil), issuerPub...) + var links []*protocol.DelegationClaim + for hop := 0; hop < maxHops; hop++ { + bestID := "" + var best *protocol.DelegationClaim + for id, d := range g.delegations { + if subtle.ConstantTimeCompare(d.Grantee, current) != 1 || !d.Covers(p.Predicate) { + continue + } + if protocol.DelegationStatusAt(d, p.Now) != protocol.StatusActive { + continue + } + withdrawn := false + for _, rev := range g.revocations[id] { + if protocol.VerifyRevocationOfDelegation(rev, d) == nil { + withdrawn = true + break + } + } + if withdrawn { + continue + } + if best == nil || d.Serial > best.Serial || + (d.Serial == best.Serial && id < bestID) { + best, bestID = d, id + } + } + if best == nil { + return nil + } + links = append(links, best) + if addrInAddr(mustAddr(best.Granter), p.TrustedIssuers) { + return links + } + current = best.Granter + } + return nil } // findApproval collects every valid Allow response from the required approvers, @@ -234,6 +435,15 @@ func addrIn(a string, set []address.Address) bool { return false } +func addrInAddr(a address.Address, set []address.Address) bool { + for _, s := range set { + if s.Equal(a) { + return true + } + } + return false +} + func addrToAddress(s string) address.Address { a, _ := address.Parse(s) return a @@ -246,3 +456,78 @@ func mustAddr(pub []byte) address.Address { } return id.Address() } + +// defaultMaxRotations bounds key-succession chains when the policy is silent. +const defaultMaxRotations = 4 + +// resolveRotationChain searches for a sequence of confirmed rotations from +// the claim's issuer up to a trusted root. Every link must be fully verified +// (both signatures plus the hash binding), inside its request window, fresh +// under Policy.RotationMaxAge when set, and unambiguous — competing confirms +// for one request make that link unusable rather than picking a winner. +func (g *Graph) resolveRotationChain(issuerPub []byte, p Policy) ([]address.Address, bool) { + maxHops := p.MaxDepth + if maxHops <= 0 { + maxHops = defaultMaxRotations + } + + current := append([]byte(nil), issuerPub...) + chain := []address.Address{mustAddr(current)} + + for hop := 0; hop < maxHops; hop++ { + reqID := g.findConfirmedRequest(current, p) + if reqID == "" { + return nil, false + } + req := g.rotRequests[reqID] + root := mustAddr(req.Predecessor) + chain = append(chain, root) + if addrInAddr(root, p.TrustedIssuers) { + return chain, true + } + current = append([]byte(nil), req.Predecessor...) + } + return nil, false +} + +// findConfirmedRequest returns the id of the newest valid request whose +// successor is current, or "" when none qualifies. Freshness under +// Policy.RotationMaxAge is enforced on the confirm's own timestamp. +func (g *Graph) findConfirmedRequest(successorPub []byte, p Policy) string { + bestReqID := "" + var bestConf *rotationConfirm + for reqID, req := range g.rotRequests { + if subtle.ConstantTimeCompare(req.Successor, successorPub) != 1 { + continue + } + confirms := g.rotConfirms[reqID] + if len(confirms) != 1 { + continue // no consent, or equivocation: unusable either way + } + conf := confirms[0] + + // Full binding check against stored bytes. + reqEnv := g.rotEnv[reqID] + if _, err := protocol.VerifyKeyRotationConfirm( + reqEnv.TCE, reqEnv.Signature, conf.env.TCE, conf.env.Signature); err != nil { + continue + } + if !protocol.RotationWindowOK(req, conf.c) { + continue + } + if p.RotationMaxAge > 0 { + if conf.c.CreatedAt > p.Now && conf.c.CreatedAt-p.Now > protocol.MaxClockSkew { + continue // dated too far in the future: hostile clock + } + if p.Now > conf.c.CreatedAt && p.Now-conf.c.CreatedAt > p.RotationMaxAge+protocol.MaxClockSkew { + continue // stale: the link no longer counts as recent consent + } + } + if bestConf == nil || conf.id < bestConf.id { + // Newest request wins on ties of confirm ids; requests carry no + // serial, so content order is the deterministic fallback. + bestReqID, bestConf = reqID, conf + } + } + return bestReqID +} diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index b0470f8..96c3418 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -1,6 +1,7 @@ package verify_test import ( + "bytes" "testing" "time" @@ -294,3 +295,50 @@ func TestRejectsBadSignature(t *testing.T) { t.Fatal("expected Add to reject bad signature") } } + +func TestTrustedIssuersFilter(t *testing.T) { + good, _ := signer.Generate() + bad, _ := signer.Generate() + subject, _ := signer.Generate() + + g := verify.NewGraph() + addClaim := func(issuer *signer.Signer, nonce byte) { + t.Helper() + c := &protocol.Claim{ + Issuer: issuer.Public(), + Subject: subject.Public(), + Claims: map[string]tce.Value{"mod": tce.Bool(true)}, + CreatedAt: base - 100, + Serial: 1, + Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize), + } + cb, _ := protocol.EncodeClaim(c) + if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil { + t.Fatal(err) + } + } + addClaim(bad, 0x11) + + // With the filter, the untrusted claim does not satisfy the policy. + res := g.Evaluate(verify.Policy{ + Subject: subject.Address(), + Predicate: "mod", + Now: base + 10, + TrustedIssuers: []address.Address{good.Address()}, + }) + if res.Trusted || res.Issuer.String() != "" && res.Issuer.IsZero() == false { + t.Fatalf("untrusted issuer accepted: %+v", res) + } + + // The same claim satisfies once its issuer is trusted. + addClaim(good, 0x12) + res2 := g.Evaluate(verify.Policy{ + Subject: subject.Address(), + Predicate: "mod", + Now: base + 10, + TrustedIssuers: []address.Address{good.Address()}, + }) + if !res2.Trusted || res2.Issuer.String() != good.Address().String() { + t.Fatalf("trusted issuer rejected: %q", res2.Reason) + } +} diff --git a/testdata/vectors/pow_vectors.json b/testdata/vectors/pow_vectors.json new file mode 100644 index 0000000..87772c4 --- /dev/null +++ b/testdata/vectors/pow_vectors.json @@ -0,0 +1,91 @@ +{ + "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/testdata/vectors/tce_vectors.json b/testdata/vectors/tce_vectors.json index 77116f1..41375e9 100644 --- a/testdata/vectors/tce_vectors.json +++ b/testdata/vectors/tce_vectors.json @@ -201,6 +201,93 @@ "audience": "trust.n1ko.dev", "created_at": 1700000000 } + }, + { + "name": "delegation/minimal", + "description": "A grant of voice: granter lets grantee issue claims covering the listed predicate. max_depth 0 forbids re-delegation.", + "tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100070100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c6167020080e2cfaa068085d5aa060110202122232425262728292a2b2c2d2e2f", + "tce_len": 135, + "object_id_hex": "b95aad8b966adb33e26d38a7de5e631450754ed3e0c04e90d92ca1f48aaad5de", + "signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "signature_hex": "81a50298543de5eac55ec3a34bb68e59ff6d2be0b3dc2bdb89b8b244afc464cd588bb75ae5d118d299f8bee471807f9a0d71f736699c761853e0f3600f85b109", + "json": { + "type": "delegation", + "version": 1, + "granter": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "grantee": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s", + "predicates": { + "example.flag": true + }, + "max_depth": 0, + "created_at": 1700000000, + "expires_at": 1700086400, + "serial": 1, + "nonce": "202122232425262728292a2b2c2d2e2f" + } + }, + { + "name": "delegation/multi-predicate", + "description": "Three exact predicates (map ordering exercised), two permitted re-delegation hops below the grantee, no expiry.", + "tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100070100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3940307612e66697273740208622e7365636f6e640207632e7468697264020280e2cfaa06000210303132333435363738393a3b3c3d3e3f", + "tce_len": 145, + "object_id_hex": "2664d9379faf21f17b6be1874b2858fbc87fe92ff9881f50000fbc3bb6a5d5bd", + "signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "signature_hex": "6cd8541115b2828d918e88524c3ea405baad9427294976272f969c4548d1ec3c707dfade3fc0e486e6593cb8e1af19b1cc5341fc14449c76968199e18516ec03", + "json": { + "type": "delegation", + "version": 1, + "granter": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "grantee": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s", + "predicates": { + "a.first": true, + "b.second": true, + "c.third": true + }, + "max_depth": 2, + "created_at": 1700000000, + "expires_at": 0, + "serial": 2, + "nonce": "303132333435363738393a3b3c3d3e3f" + } + }, + { + "name": "key_rotation/request", + "description": "The successor claims succession from the predecessor; meaningless without the confirm.", + "tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100080100208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39400208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c80e2cfaa069ee2cfaa06", + "tce_len": 101, + "object_id_hex": "b3e389d0198cd542ff403fa75e91d1ed2696234e04b1080bf98f47c7c88cc390", + "signer_pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394", + "signer_address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s", + "signature_hex": "eb6e984f78996a04714d2aa55bb936d06f6352f52fbd911e545b7f4ed22438ecf8a1cd52c133bff77201370536f31516175f7ab7f6b6f5b45a85d7400fa24507", + "json": { + "type": "key_rotation_request", + "version": 1, + "successor": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s", + "predecessor": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "created_at": 1700000000, + "expires_at": 1700000030, + "object_id_hex": "b3e389d0198cd542ff403fa75e91d1ed2696234e04b1080bf98f47c7c88cc390" + } + }, + { + "name": "key_rotation/confirm", + "description": "The predecessor's consent, bound to the request by content ID.", + "tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100090120b3e389d0198cd542ff403fa75e91d1ed2696234e04b1080bf98f47c7c88cc3908ae2cfaa0610000102030405060708090a0b0c0d0e0f", + "tce_len": 78, + "object_id_hex": "1aef5352dc0976414e6c0c53f74f93cc0309c1c20f37023aaaedf940260aa45c", + "signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + "signature_hex": "13fe0b160cf125de5bc661846b7c10d3695016bae9c2e23b5e0c22d80dc5c405a44f714909360ebb88c1934325c56f45f389aca6b6b77f9408fed3a253d61d0e", + "json": { + "type": "key_rotation_confirm", + "version": 1, + "rotation_hash": "b3e389d0198cd542ff403fa75e91d1ed2696234e04b1080bf98f47c7c88cc390", + "created_at": 1700000010, + "nonce": "000102030405060708090a0b0c0d0e0f", + "request_object_id_hex": "b3e389d0198cd542ff403fa75e91d1ed2696234e04b1080bf98f47c7c88cc390" + } } ], "number_canonicalization": { diff --git a/tools/reference/pow_reference.py b/tools/reference/pow_reference.py new file mode 100644 index 0000000..bf25181 --- /dev/null +++ b/tools/reference/pow_reference.py @@ -0,0 +1,230 @@ +#!/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)) diff --git a/tools/reference/tce_reference.py b/tools/reference/tce_reference.py index 2bc4b30..e4db6f0 100644 --- a/tools/reference/tce_reference.py +++ b/tools/reference/tce_reference.py @@ -42,6 +42,9 @@ TAG_REVOCATION = 0x03 TAG_APPROVAL_REQUEST = 0x04 TAG_APPROVAL_RESPONSE = 0x05 TAG_AUTH_ASSERTION = 0x06 +TAG_DELEGATION = 0x07 +TAG_KEY_ROTATION = 0x08 +TAG_KEY_ROTATION_CONF = 0x09 # Value tags. Separate tags for false and true remove any question of how a # boolean is encoded. @@ -85,6 +88,8 @@ MAX_NUMBER_TOKEN = 64 MAX_TCE_IDENTITY = 1024 MAX_TCE_CLAIM = 4096 +MAX_TCE_DELEGATION = 2048 +MAX_TCE_KEY_ROTATION = 1024 MAX_TCE_REVOCATION = 1024 MAX_TCE_APPROVAL_REQUEST = 8192 MAX_TCE_APPROVAL_RESPONSE = 1024 @@ -397,6 +402,87 @@ def tce_claim( return _check_size(body, MAX_TCE_CLAIM, "claim") +def tce_delegation( + *, + granter: bytes, + grantee: bytes, + predicates: dict, + max_depth: int, + created_at: int, + expires_at: int, + serial: int, + nonce: bytes, +) -> bytes: + """DelegationClaim: granter lets grantee speak for listed predicates.""" + if len(nonce) != NONCE_LEN: + raise EncodingError("nonce: must be 16 bytes") + if expires_at != 0 and expires_at <= created_at: + raise EncodingError("expires_at: must be 0 or after created_at") + if not 1 <= len(predicates) <= 32: + raise EncodingError("predicates: must hold between 1 and 32 entries") + for k, v in predicates.items(): + if v is not True: + raise EncodingError("predicates: every value must be true") + body = ( + MAGIC + + bytes([TAG_DELEGATION]) + + uvarint(PROTOCOL_VERSION) + + enc_identity(granter) + + enc_identity(grantee) + + enc_map(predicates, min_entries=1) + + uvarint(max_depth) + + enc_timestamp(created_at, field="created_at") + + enc_timestamp(expires_at, field="expires_at", allow_zero=True) + + uvarint(serial) + + enc_bytes(nonce) + ) + return _check_size(body, MAX_TCE_DELEGATION, "delegation") + + +def tce_key_rotation_request( + *, + successor: bytes, + predecessor: bytes, + created_at: int, + expires_at: int, +) -> bytes: + """KeyRotationRequest: the incoming key claims succession.""" + if expires_at <= created_at: + raise EncodingError("expires_at: must be after created_at") + if expires_at - created_at > 60: + raise EncodingError("expires_at: lifetime exceeds 60 seconds") + body = ( + MAGIC + + bytes([TAG_KEY_ROTATION]) + + uvarint(PROTOCOL_VERSION) + + enc_identity(successor) + + enc_identity(predecessor) + + enc_timestamp(created_at, field="created_at") + + enc_timestamp(expires_at, field="expires_at") + ) + return _check_size(body, MAX_TCE_KEY_ROTATION, "key_rotation_request") + + +def tce_key_rotation_confirm( + *, + rotation_hash: bytes, + created_at: int, + nonce: bytes, +) -> bytes: + """KeyRotationConfirm: the predecessor consents to one exact request.""" + if len(nonce) != NONCE_LEN: + raise EncodingError("nonce: must be 16 bytes") + body = ( + MAGIC + + bytes([TAG_KEY_ROTATION_CONF]) + + uvarint(PROTOCOL_VERSION) + + enc_bytes(rotation_hash) + + enc_timestamp(created_at, field="created_at") + + enc_bytes(nonce) + ) + return _check_size(body, MAX_TCE_KEY_ROTATION, "key_rotation_confirm") + + def tce_revocation( *, issuer: bytes, claim_id: bytes, reason: str, created_at: int, nonce: bytes ) -> bytes: @@ -871,6 +957,122 @@ def build_vectors() -> dict: }, ) + # 9. Delegation: NikoCraft grants Niko the right to speak. + deleg = tce_delegation( + granter=NIKOCRAFT.pub, + grantee=NIKO.pub, + predicates={"example.flag": True}, + max_depth=0, + created_at=T0, + expires_at=T0 + 86_400, + serial=1, + nonce=N3, + ) + add( + "delegation/minimal", + "A grant of voice: granter lets grantee issue claims covering the " + "listed predicate. max_depth 0 forbids re-delegation.", + deleg, + NIKOCRAFT, + { + "json": { + "type": "delegation", + "version": 1, + "granter": NIKOCRAFT.address, + "grantee": NIKO.address, + "predicates": {"example.flag": True}, + "max_depth": 0, + "created_at": T0, + "expires_at": T0 + 86_400, + "serial": 1, + "nonce": N3.hex(), + } + }, + ) + + # 10. Delegation with several predicates and re-delegation depth. + deleg2 = tce_delegation( + granter=NIKOCRAFT.pub, + grantee=NIKO.pub, + predicates={"a.first": True, "b.second": True, "c.third": True}, + max_depth=2, + created_at=T0, + expires_at=0, + serial=2, + nonce=N4, + ) + add( + "delegation/multi-predicate", + "Three exact predicates (map ordering exercised), two permitted " + "re-delegation hops below the grantee, no expiry.", + deleg2, + NIKOCRAFT, + { + "json": { + "type": "delegation", + "version": 1, + "granter": NIKOCRAFT.address, + "grantee": NIKO.address, + "predicates": {"a.first": True, "b.second": True, "c.third": True}, + "max_depth": 2, + "created_at": T0, + "expires_at": 0, + "serial": 2, + "nonce": N4.hex(), + } + }, + ) + + # 11. Key rotation pair: Niko succeeds... the parties rotate between + # themselves for vector purposes. The confirm binds to the request by ID. + rot_req = tce_key_rotation_request( + successor=NIKO.pub, + predecessor=NIKOCRAFT.pub, + created_at=T0, + expires_at=T0 + 30, + ) + rot_req_id = object_id(rot_req) + add( + "key_rotation/request", + "The successor claims succession from the predecessor; meaningless " + "without the confirm.", + rot_req, + NIKO, + { + "json": { + "type": "key_rotation_request", + "version": 1, + "successor": NIKO.address, + "predecessor": NIKOCRAFT.address, + "created_at": T0, + "expires_at": T0 + 30, + "object_id_hex": rot_req_id.hex(), + } + }, + ) + + rot_conf = tce_key_rotation_confirm( + rotation_hash=rot_req_id, + created_at=T0 + 10, + nonce=N1, + ) + add( + "key_rotation/confirm", + "The predecessor's consent, bound to the request by content ID.", + rot_conf, + NIKOCRAFT, + { + "json": { + "type": "key_rotation_confirm", + "version": 1, + "rotation_hash": rot_req_id.hex(), + "created_at": T0 + 10, + "nonce": N1.hex(), + "request_object_id_hex": rot_req_id.hex(), + } + }, + ) + return { "format": "trust.n1ko.dev TCE test vectors", "tce_version": 1,