feat: network layer — PoW, checkpoint chain, gossip, light node, WS, delegation, rotation, BFT

- BLAKE3 keyed proof-of-work on object storage and auth challenges,
  with frozen vectors cross-checked against an independent Python
  reference implementing the single-block hash it needs.
- Sparse Merkle trie over object IDs: order-independent roots,
  inclusion and absence proofs (internal/smt).
- Signed checkpoint chain per relay: transport key amendment to INV-1,
  /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe
  epoch continuity (internal/checkpoint).
- Head gossip with TOFU pinning and equivocation detection; light node
  (cmd/lightnode) that stores no history: quorum of pinned relays,
  every served object proven against the agreed root, LRU disk cache.
- WebSocket streaming on relay and light node (coder/websocket):
  scoped channels mirroring REST, raw envelopes verified client-side;
  light node marks streamed objects unproven until checkpoint coverage.
- Protocol v1 additions: DelegationClaim tag 0x07 with deterministic
  chain resolution in verify.Graph, KeyRotationRequest/Confirm tags
  0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge;
  spec sections, frozen vectors appended byte-identically, Python
  reference extended.
- Optional permissioned BFT finality over gossip (internal/bft):
  prevote/precommit with quorum certificates verifiable offline.
- Quick wins: Policy.TrustedIssuers, per-type stored metrics,
  batch fetch, lexicographic lists with stable cursor pagination.
- Security review of the network layer (docs/SECURITY-REVIEW.md) with
  findings F-01..F-09; hub send/close race and unstable pagination
  fixed under review.

12 packages green, vet/gofmt clean, protocol fuzzing stable.
This commit is contained in:
Niko Marmeladkov 2026-08-25 20:38:40 +03:00
parent 0f4a210c04
commit 20cc52c3a5
70 changed files with 10109 additions and 182 deletions

3
.gitignore vendored
View file

@ -11,3 +11,6 @@
# Python caches
__pycache__/
*.pyc
# Local build output
/niko_trust

94
cmd/lightnode/main.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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": "<hex>" }
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
POST /v1/pow/challenge {"purpose":"auth"} -> { "key", "difficulty", "ttl" }
POST /v1/auth/challenge ({"pow":{...}}) -> { "challenge": "<hex>" }
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
```
When `-pow-auth-bits` is enabled, `/v1/auth/challenge` requires a solved
challenge first; see [POW.md](POW.md).
Send the token as `Authorization: Bearer <token>` or `?token=<token>`. A session
is valid for 30 minutes; each challenge is single-use and expires after 5
minutes. Scopes: `read:claims`, `read:requests`, `read:responses`,
@ -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": "<subject address>" }
{ "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": "<base64>", "signature": "<base64>" }
POST { "tce": "<base64>", "signature": "<base64>",
"pow": { "key": "<hex>", "counter": <uint> } }
200 { "object_id": "<content-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=<object_id>` 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

78
docs/BFT.md Normal file
View file

@ -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 = ⌊(n1)/3⌋`.
A stalled or equivocating proposer costs one round timeout, then leadership
rotates. All messages are canonical bytes signed with Ed25519; phases live
in the signing domain, so a prevote can never be replayed as a precommit.
## 3. Guarantees and simplifications
Safety: two certificates for one height require ≥⅓ Byzantine validators.
Liveness: finality proceeds while ≥ quorum validators are online and honest;
a minority outage stalls that height until they return (fail-stop, never
fork).
Documented v1 simplifications, none safety-relevant:
- Single in-flight round per height on the driver; rotation happens through
timeouts rather than full Tendermint lock-rules.
- Validators accept a proposal whose head they have already verified via
gossip; head-body re-fetch before prevoting is future work.
- Validator-set changes are manual: update config, restart.
## 4. Endpoints
| Method | Path | Purpose |
|--------|-------------------------------|--------------------------------------------|
| POST | `/v1/bft/proposal` | validator fan-out: signed proposal |
| POST | `/v1/bft/vote` | validator fan-out: signed prevote/precommit|
| GET | `/v1/bft/state` | `{enabled, height, last_finalized}` |
| GET | `/v1/bft/certificate/{h}` | finality certificate for a height |
Light nodes pin the validator keys (`BFTValidators`); `/v1/bft/certificate`
then serves only certificates that verify against exactly those keys, so a
light node can demand "quorum-agreed root" instead of trusting any single
mirror's checkpoint.
## 5. Composition
```
relays ──gossip──▶ heads ──▶ validators vote ──▶ certificate
light nodes ◀── verify offline ──────────────┘
```
Gossip remains the data plane; BFT is a thin consensus overlay on which head
counts as canon per height. Disabling it returns the system to plain
checkpoint-gossip with split-view detection by comparison.

105
docs/CHECKPOINT.md Normal file
View file

@ -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 `<data-dir>/relay_key.seed` (0600). In-memory mode
generates an ephemeral key per process; heads are then valid only while the
process lives.
## 3. Trie and proofs
Hash domain (SHA-256):
```
leaf = H(0x00 || object_id)
branch = H(0x01 || uvarint(prefixLen) || prefixBits || left || right)
empty = H(0x02)
```
The branch prefix is inside the hash so trie shape is committed. Branches
split exactly where their keys first differ, so shape is canonical.
- **Inclusion proof** (`GET /v1/proof/object/{id}`): sibling path from the
leaf to the root. Verifies only for the exact object id.
- **Absence proof** (`GET /v1/proof/absent/{id}`): witnesses the spot where
the id would live — either the neighbouring leaf reached by walking the
id's bits, or the branch whose prefix the id leaves mid-way. This is what
lets a consumer prove "nothing else about this subject exists" rather than
trusting a relay's silence.
Proofs verify against a root the consumer obtained independently (from a
quorum of peers), never against a root the serving relay just made up.
## 4. Endpoints
| Method | Path | Purpose |
|--------|---------------------------|------------------------------------------------|
| GET | `/v1/checkpoint/latest` | newest signed head `{bytes, signature, id, public_key, checkpoint}` |
| GET | `/v1/checkpoint/{epoch}` | a specific historical head |
| GET | `/v1/proof/object/{id}` | inclusion proof against the current root |
| GET | `/v1/proof/absent/{id}` | absence proof against the current root |
| POST | `/v1/gossip/checkpoint` | announce a peer's signed head |
| GET | `/v1/peers/heads` | observed peer heads |
`bytes` is authoritative (base64); the decoded `checkpoint` view is a
convenience, mirroring how envelopes work. Gossip accepts a head only if the
signature verifies under the announcing key and the id matches the bytes;
same-key/same-epoch/different-bytes is recorded as divergence
(`trust_gossip_divergence`) and exposed, never silently resolved.
Configuration flags: `-ckpt-interval` (default 60s), `-ckpt-every`
(default 128 new objects).
## 5. Light nodes
`cmd/lightnode` follows heads from `-peers`, requires `-quorum` distinct
relay keys to agree on one root (TOFU pinning unless `-pin-keys` given), then
serves the read API locally:
- every served object was proven present in the agreed set before caching;
- every absence claim was proven against the agreed root;
- storage is the working set plus heads — no history;
- cached objects keep serving after all peers disappear.
Run it against two or more independent relays holding the same data:
```
lightnode -peers https://a.example,https://b.example \
-quorum 2 -cache-dir /var/lib/lightnode -addr :8090
```
The node makes no authorization decisions; consumers verify statements
locally as before (INV-5). Trust your own verification stack, not the mirror.

66
docs/DELEGATION.md Normal file
View file

@ -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 ≥ k1i`.
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.

100
docs/POW.md Normal file
View file

@ -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": "<hex>", "counter": <uint> }`.
### Authentication (`POST /v1/auth/challenge`)
Identical, with `{"purpose": "auth"}` and target = 32 zero bytes; the solved
proof accompanies the request body as `"pow"`. Binding to nothing beyond the
fresh key is intentional here: single-use consumption carries the protection.
### Defaults
| Tier | Flag | Default |
|---|---|---|
| storage | `-pow-put-bits` | 22 (~4M hashes; well under a second on desktop, seconds on a phone) |
| authentication | `-pow-auth-bits` | 18 |
`0` disables a tier. Values above 30 are clamped. Challenge issuance itself is
additionally capped at 30/min per IP.
## 4. Failure modes
| Situation | Response |
|---|---|
| tier enabled, no `pow` field | `429 proof of work required` |
| malformed key hex | `429 malformed proof of work` |
| unknown / expired / already-spent key | `429 unknown or expired challenge` |
| hash misses the target | `429 invalid proof of work` |
| challenge endpoint over rate limit | `429 rate limited` |
A spent-but-invalid challenge is still consumed: guessing counters must not
get free retries against one key.
## 5. Reference implementations
- Go: `internal/pow``Sum`, `Verify`, `Solve`, `LeadingZeroBits`.
- Python reference (independent, implements the single-block BLAKE3 it needs
from scratch): `tools/reference/pow_reference.py`.
- Frozen vectors: 6 accept cases (including the zero-target authentication
binding, up to difficulty 16) with minimal recorded counters, plus rejects
(off-by-one counter, wrong key) and configuration rejects (difficulty > 30).
Any implementation agreeing with both files agrees with the specification.

View file

@ -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`

66
docs/ROTATION.md Normal file
View file

@ -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.88.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.

104
docs/SECURITY-REVIEW.md Normal file
View file

@ -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.

View file

@ -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

4
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

347
internal/bft/bft.go Normal file
View file

@ -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
}

264
internal/bft/mesh_test.go Normal file
View file

@ -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
}

307
internal/bft/validator.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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)
}
}
}

212
internal/lightnode/auth.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

358
internal/lightnode/ws.go Normal file
View file

@ -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

168
internal/pow/pow.go Normal file
View file

@ -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
}

216
internal/pow/pow_test.go Normal file
View file

@ -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))
}
}

View file

@ -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
}
})
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}

View file

@ -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:

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

216
internal/server/bft.go Normal file
View file

@ -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)
}

View file

@ -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[:]),
})
}

View file

@ -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[:])
}

105
internal/server/gossip.go Normal file
View file

@ -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})
}

View file

@ -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])
}
}

View file

@ -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()

View file

@ -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)
}

171
internal/server/pow.go Normal file
View file

@ -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{}
}

295
internal/server/pow_test.go Normal file
View file

@ -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)
}

View file

@ -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) {

View file

@ -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

View file

@ -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

318
internal/server/ws.go Normal file
View file

@ -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)
}
}

348
internal/server/ws_test.go Normal file
View file

@ -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)
}
}

85
internal/smt/fuzz_test.go Normal file
View file

@ -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")
}
})
}

385
internal/smt/proofs.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

271
internal/smt/smt.go Normal file
View file

@ -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
}

View file

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

View file

@ -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 {

View file

@ -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) {

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}

91
testdata/vectors/pow_vectors.json vendored Normal file
View file

@ -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"
}
]
}

View file

@ -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": {

View file

@ -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))

View file

@ -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,