refactor: drop PoW, checkpoint chain, gossip, light node and BFT; keep WS
The decentralization stack overcomplicated the project. Removed:
internal/pow, internal/smt, internal/checkpoint, internal/bft,
internal/lightnode, cmd/lightnode, relay gossip/checkpoint/proof/BFT
endpoints, their docs, vectors and the blake3 dependency.
Kept: WebSocket streaming on the relay, the full protocol v1 object set
including DelegationClaim (0x07) and KeyRotation request/confirm
(0x08/0x09) with chain resolution in verify.Graph, TrustedIssuers,
batch fetch, stable cursor pagination, per-type metrics.
INV-1 reverts to its original form: the relay holds no keys again.
Everything removed remains reachable at commit 20cc52c.
This commit is contained in:
parent
b5c0200491
commit
e23a3cab59
41 changed files with 53 additions and 7213 deletions
|
|
@ -1,94 +0,0 @@
|
|||
// Command lightnode runs a verifying mirror of the relay API that stores no
|
||||
// history. It follows signed checkpoint heads from its peers, requires a
|
||||
// quorum of relay keys to agree on one root, and serves objects fetched on
|
||||
// demand — each one proven against the agreed set before it is cached or
|
||||
// returned.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8090", "listen address")
|
||||
peers := flag.String("peers", "", "comma-separated relay base URLs (required)")
|
||||
pinKeys := flag.String("pin-keys", "", "comma-separated hex relay public keys; empty = trust-on-first-use")
|
||||
quorum := flag.Int("quorum", 0, "relay keys required to agree on one root (0 = all peers)")
|
||||
cacheDir := flag.String("cache-dir", "", "directory for fetched objects (empty = memory only)")
|
||||
cacheMaxMB := flag.Int64("cache-max-mb", 256, "cache size cap in MiB")
|
||||
interval := flag.Duration("refresh-interval", 10*time.Second, "checkpoint polling interval")
|
||||
stream := flag.Bool("stream", false, "mirror upstream WebSocket streams to local clients (GET /v1/ws)")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
if *peers == "" {
|
||||
logger.Error("-peers is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
node := lightnode.New(lightnode.Config{
|
||||
Peers: strings.Split(*peers, ","),
|
||||
PinKeys: splitNonEmpty(*pinKeys),
|
||||
Quorum: *quorum,
|
||||
CacheDir: *cacheDir,
|
||||
CacheMaxBytes: *cacheMaxMB << 20,
|
||||
})
|
||||
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
go node.Run(ctx, *interval)
|
||||
if *stream {
|
||||
wsn, err := node.WS()
|
||||
if err != nil {
|
||||
logger.Error("websocket subsystem unavailable", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go wsn.RunWS(ctx)
|
||||
}
|
||||
|
||||
h := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: node.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
logger.Info("light node listening", "addr", *addr, "peers", *peers)
|
||||
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("listen", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
stop()
|
||||
|
||||
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = h.Shutdown(ctx2)
|
||||
logger.Info("stopped")
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -24,13 +23,6 @@ func main() {
|
|||
addr := flag.String("addr", "", "listen address (overrides config)")
|
||||
audience := flag.String("audience", "", "server audience bound into auth assertions (overrides config)")
|
||||
data := flag.String("data", "", "directory to persist objects (overrides config)")
|
||||
powPutBits := flag.Int("pow-put-bits", 22, "proof-of-work difficulty for storing objects, leading zero bits (0 = off)")
|
||||
powAuthBits := flag.Int("pow-auth-bits", 18, "proof-of-work difficulty for auth challenge issuance, leading zero bits (0 = off)")
|
||||
ckptInterval := flag.Duration("ckpt-interval", time.Minute, "maximum time between signed checkpoints when the object set changed")
|
||||
ckptEvery := flag.Int("ckpt-every", 128, "sign a checkpoint after this many new objects (0 = interval only)")
|
||||
bftValidators := flag.String("bft-validators", "", "comma-separated validator public keys (hex); enables BFT finality")
|
||||
bftURLs := flag.String("bft-urls", "", "comma-separated validator base URLs, aligned with keys")
|
||||
bftTimeout := flag.Duration("bft-round-timeout", 2*time.Second, "BFT round timeout")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := server.LoadConfig(*configPath)
|
||||
|
|
@ -56,22 +48,8 @@ func main() {
|
|||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel(cfg.LogLevel)}))
|
||||
srv := server.New(cfg,
|
||||
server.WithPow(*powPutBits, *powAuthBits),
|
||||
server.WithCheckpoints(cfg.DataDir, server.CheckpointConfig{Interval: *ckptInterval, EveryN: *ckptEvery}),
|
||||
)
|
||||
if *bftValidators != "" && *bftURLs != "" {
|
||||
srv.SetBFT(server.BFTConfig{
|
||||
ValidatorKeys: splitCSV(*bftValidators),
|
||||
ValidatorURLs: splitCSV(*bftURLs),
|
||||
RoundTimeout: *bftTimeout,
|
||||
})
|
||||
}
|
||||
srv := server.New(cfg)
|
||||
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
srv.StartCheckpoints(ctx)
|
||||
srv.StartBFT(ctx)
|
||||
defer stop()
|
||||
h := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: srv.Handler(),
|
||||
|
|
@ -80,7 +58,7 @@ func main() {
|
|||
|
||||
go func() {
|
||||
logger.Info("trust relay listening", "addr", cfg.ListenAddr, "audience", cfg.Audience, "data_dir", cfg.DataDir)
|
||||
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := h.ListenAndServe(); err != nil && http.ErrServerClosed != err {
|
||||
logger.Error("listen", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -89,7 +67,6 @@ func main() {
|
|||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
stop()
|
||||
|
||||
logger.Info("shutting down")
|
||||
ctx2, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
|
@ -100,16 +77,6 @@ func main() {
|
|||
logger.Info("stopped")
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logLevel(s string) slog.Level {
|
||||
var l slog.Level
|
||||
if err := l.UnmarshalText([]byte(s)); err != nil {
|
||||
|
|
|
|||
31
docs/API.md
31
docs/API.md
|
|
@ -9,14 +9,10 @@ Read endpoints require a session token. Obtain one with the challenge/assert
|
|||
handshake (AuthAssertion bound to the server's `audience`):
|
||||
|
||||
```
|
||||
POST /v1/pow/challenge {"purpose":"auth"} -> { "key", "difficulty", "ttl" }
|
||||
POST /v1/auth/challenge ({"pow":{...}}) -> { "challenge": "<hex>" }
|
||||
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
|
||||
POST /v1/auth/challenge -> { "challenge": "<hex>" }
|
||||
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
|
||||
```
|
||||
|
||||
When `-pow-auth-bits` is enabled, `/v1/auth/challenge` requires a solved
|
||||
challenge first; see [POW.md](POW.md).
|
||||
|
||||
Send the token as `Authorization: Bearer <token>` or `?token=<token>`. A session
|
||||
is valid for 30 minutes; each challenge is single-use and expires after 5
|
||||
minutes. Scopes: `read:claims`, `read:requests`, `read:responses`,
|
||||
|
|
@ -28,22 +24,15 @@ Rate limit: `POST /v1/auth/challenge` is capped at **30/min per IP** (`429`).
|
|||
|
||||
| Method | Path | Auth | Query / body | Purpose |
|
||||
|--------|--------------------------|-------------|-----------------------------|------------------------------------------|
|
||||
| POST | `/v1/objects` | none | `Envelope` + optional `pow` | Store a signed object (claim/request/...) |
|
||||
| POST | `/v1/objects` | none | `Envelope` body | Store a signed object (claim/request/...) |
|
||||
| GET | `/v1/objects/{id}` | none | — | Fetch one object by content id |
|
||||
| GET | `/v1/objects?ids=a,b,c` | none | ≤100 ids | Batch fetch; missing ids are omitted |
|
||||
| POST | `/v1/pow/challenge` | none | `{"purpose":"put"\|"auth"}` | Issue a single-use PoW challenge key |
|
||||
| GET | `/v1/claims` | session | `subject`, `limit`,`offset` | Claims about a subject |
|
||||
| GET | `/v1/requests` | session | `recipient`,`limit`,`offset`| Approval requests to a recipient |
|
||||
| GET | `/v1/responses` | session | `request`,`limit`,`offset` | Responses to a request |
|
||||
| GET | `/v1/revocations` | session | `claim`,`limit`,`offset` | Revocations targeting a claim |
|
||||
| GET | `/v1/config` | none | — | `{ "audience": "...", "relay_pubkey": "..." }` |
|
||||
| GET | `/v1/config` | none | — | `{ "audience": "..." }` |
|
||||
| GET | `/v1/metrics` | none | — | Prometheus-style metrics |
|
||||
| GET | `/v1/checkpoint/latest` | none | — | Newest signed object-set commitment |
|
||||
| GET | `/v1/checkpoint/{epoch}` | none | — | A specific historical head |
|
||||
| GET | `/v1/proof/object/{id}` | none | — | Inclusion proof against the current root |
|
||||
| GET | `/v1/proof/absent/{id}` | none | — | Absence proof against the current root |
|
||||
| POST | `/v1/gossip/checkpoint` | none | peer head announcement | Record another relay's signed head |
|
||||
| GET | `/v1/peers/heads` | none | — | Observed peer heads |
|
||||
| GET | `/v1/ws` | session | WebSocket control frames | Live stream of newly stored envelopes |
|
||||
| GET | `/v1/healthz` | none | — | Liveness (`200`) |
|
||||
| GET | `/v1/readyz` | none | — | Readiness (`200` / `503`) |
|
||||
|
|
@ -76,8 +65,6 @@ if you had fetched it yourself. Limits: ≤16 subscriptions per connection;
|
|||
a client that cannot keep up is disconnected rather than allowed to stall
|
||||
broadcasts; the session's 30-minute lifetime applies mid-stream.
|
||||
|
||||
Checkpoint and proof endpoints are specified in
|
||||
[CHECKPOINT.md](CHECKPOINT.md); the proof-of-work scheme in [POW.md](POW.md).
|
||||
|
||||
## Storing objects (`POST /v1/objects`)
|
||||
|
||||
|
|
@ -96,16 +83,13 @@ Checkpoint and proof endpoints are specified in
|
|||
Request / response shape:
|
||||
|
||||
```
|
||||
POST { "tce": "<base64>", "signature": "<base64>",
|
||||
"pow": { "key": "<hex>", "counter": <uint> } }
|
||||
POST { "tce": "<base64>", "signature": "<base64>" }
|
||||
200 { "object_id": "<content-id>" }
|
||||
422 { "error": "server: subject ... quota exceeded" }
|
||||
413 { "error": "payload too large" }
|
||||
429 { "error": "proof of work required" | "invalid proof of work" | "rate limited" }
|
||||
429 { "error": "rate limited" }
|
||||
```
|
||||
|
||||
The `pow` field is required when `-pow-put-bits` > 0 (the default); see
|
||||
[POW.md](POW.md) for the scheme.
|
||||
|
||||
## Listing endpoints (claims/requests/responses/revocations)
|
||||
|
||||
|
|
@ -131,7 +115,8 @@ decode the `TCE` to read the issuer/subject/fields.
|
|||
- `GET /v1/metrics` → `text/plain` Prometheus exposition with at least:
|
||||
`trust_objects_stored`, `trust_objects_rejected`, `trust_challenges_issued`,
|
||||
`trust_assertions_ok`, `trust_assertions_failed`, `trust_sessions_active`,
|
||||
`trust_pow_ok`, `trust_pow_failed`.
|
||||
`trust_ws_connections`, `trust_ws_messages_sent`, and one
|
||||
`trust_objects_stored_<type>` counter per object type.
|
||||
|
||||
## Status code summary
|
||||
|
||||
|
|
|
|||
78
docs/BFT.md
78
docs/BFT.md
|
|
@ -1,78 +0,0 @@
|
|||
# BFT finality over checkpoint gossip
|
||||
|
||||
Optional permissioned finality layered on the checkpoint chain. Gossip alone
|
||||
detects divergence after the fact; a validator set prevents it from being
|
||||
presented as truth in the first place: a height commits to exactly one head,
|
||||
and everyone can check that commitment offline.
|
||||
|
||||
---
|
||||
|
||||
## 1. Roles
|
||||
|
||||
- **Validators** are relays whose transport key doubles as their validator
|
||||
key. One key, one role, no new secrets. The set is fixed in configuration
|
||||
(`-bft-validators`, `-bft-urls`) — membership changes are an operational
|
||||
ceremony, not a protocol event.
|
||||
- **Everyone else** (relays, light nodes, verifiers) needs only the public
|
||||
validator list to check a certificate.
|
||||
|
||||
## 2. Protocol
|
||||
|
||||
Per height `h` (aligned with checkpoint epochs), deterministic leader:
|
||||
|
||||
```
|
||||
proposer(h, r) = validators[ SHA-256(height‖round)[0] mod n ]
|
||||
```
|
||||
|
||||
1. The round's proposer offers its latest head.
|
||||
2. Validators **prevote** for the proposed head (or nothing).
|
||||
3. On a quorum prevote for X, each validator **precommits** X.
|
||||
4. On a quorum precommit for X the height finalizes with a *certificate*:
|
||||
the collected precommit signatures. Height advances; the finalized head
|
||||
becomes the anchor every future proposal must chain onto.
|
||||
|
||||
Quorum is `2f+1` of `n` with tolerated Byzantine validators `f = ⌊(n−1)/3⌋`.
|
||||
A stalled or equivocating proposer costs one round timeout, then leadership
|
||||
rotates. All messages are canonical bytes signed with Ed25519; phases live
|
||||
in the signing domain, so a prevote can never be replayed as a precommit.
|
||||
|
||||
## 3. Guarantees and simplifications
|
||||
|
||||
Safety: two certificates for one height require ≥⅓ Byzantine validators.
|
||||
Liveness: finality proceeds while ≥ quorum validators are online and honest;
|
||||
a minority outage stalls that height until they return (fail-stop, never
|
||||
fork).
|
||||
|
||||
Documented v1 simplifications, none safety-relevant:
|
||||
|
||||
- Single in-flight round per height on the driver; rotation happens through
|
||||
timeouts rather than full Tendermint lock-rules.
|
||||
- Validators accept a proposal whose head they have already verified via
|
||||
gossip; head-body re-fetch before prevoting is future work.
|
||||
- Validator-set changes are manual: update config, restart.
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|-------------------------------|--------------------------------------------|
|
||||
| POST | `/v1/bft/proposal` | validator fan-out: signed proposal |
|
||||
| POST | `/v1/bft/vote` | validator fan-out: signed prevote/precommit|
|
||||
| GET | `/v1/bft/state` | `{enabled, height, last_finalized}` |
|
||||
| GET | `/v1/bft/certificate/{h}` | finality certificate for a height |
|
||||
|
||||
Light nodes pin the validator keys (`BFTValidators`); `/v1/bft/certificate`
|
||||
then serves only certificates that verify against exactly those keys, so a
|
||||
light node can demand "quorum-agreed root" instead of trusting any single
|
||||
mirror's checkpoint.
|
||||
|
||||
## 5. Composition
|
||||
|
||||
```
|
||||
relays ──gossip──▶ heads ──▶ validators vote ──▶ certificate
|
||||
│
|
||||
light nodes ◀── verify offline ──────────────┘
|
||||
```
|
||||
|
||||
Gossip remains the data plane; BFT is a thin consensus overlay on which head
|
||||
counts as canon per height. Disabling it returns the system to plain
|
||||
checkpoint-gossip with split-view detection by comparison.
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# Checkpoints, proofs and light nodes
|
||||
|
||||
This document specifies the relay's signed commitment to its own object set,
|
||||
the proof endpoints built on it, and the light node that consumes both. It is
|
||||
the mechanism that turns the INV-1 caveat ("a relay can withhold anything")
|
||||
from an act of faith into a detectable event.
|
||||
|
||||
Companion documents: [TRUST-MODEL.md](TRUST-MODEL.md),
|
||||
[API.md](API.md), [PROTOCOL.md](PROTOCOL.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. The idea
|
||||
|
||||
A relay stores a set of content-addressed objects. The sparse Merkle root of
|
||||
that set (`internal/smt`) is a **function of the set alone**: two relays that
|
||||
store the same objects compute the same root regardless of insertion order or
|
||||
restart history. A relay signs `{epoch, size, root, prev, created_at}` every
|
||||
interval or every N new objects; the `prev` field chains each head to the
|
||||
hash of its predecessor, making per-relay history tamper-evident without any
|
||||
global consensus.
|
||||
|
||||
Decentralization falls out of gossiping heads: mirrors that disagree about
|
||||
what a relay stores are detectable by comparing what independent peers claim
|
||||
under the same relay key. No token, no mining, no block cadence — approval
|
||||
flows keep their 60-second lifetime, and phones can participate as light
|
||||
nodes instead of miners.
|
||||
|
||||
## 2. What the relay key may and may not do
|
||||
|
||||
INV-1 amendment: the relay holds exactly one key. Its entire power is to
|
||||
describe its own storage — signing checkpoints and nothing else. Statement
|
||||
signing still lives exclusively in `internal/identity/signer`, which server
|
||||
code cannot import (enforced by `TestServerDoesNotImportSigner`), and the
|
||||
checkpoint package never touches protocol objects. Both properties are
|
||||
source-scanned by `TestNoSigningOutsideSigner`.
|
||||
|
||||
The seed persists in `<data-dir>/relay_key.seed` (0600). In-memory mode
|
||||
generates an ephemeral key per process; heads are then valid only while the
|
||||
process lives.
|
||||
|
||||
## 3. Trie and proofs
|
||||
|
||||
Hash domain (SHA-256):
|
||||
|
||||
```
|
||||
leaf = H(0x00 || object_id)
|
||||
branch = H(0x01 || uvarint(prefixLen) || prefixBits || left || right)
|
||||
empty = H(0x02)
|
||||
```
|
||||
|
||||
The branch prefix is inside the hash so trie shape is committed. Branches
|
||||
split exactly where their keys first differ, so shape is canonical.
|
||||
|
||||
- **Inclusion proof** (`GET /v1/proof/object/{id}`): sibling path from the
|
||||
leaf to the root. Verifies only for the exact object id.
|
||||
- **Absence proof** (`GET /v1/proof/absent/{id}`): witnesses the spot where
|
||||
the id would live — either the neighbouring leaf reached by walking the
|
||||
id's bits, or the branch whose prefix the id leaves mid-way. This is what
|
||||
lets a consumer prove "nothing else about this subject exists" rather than
|
||||
trusting a relay's silence.
|
||||
|
||||
Proofs verify against a root the consumer obtained independently (from a
|
||||
quorum of peers), never against a root the serving relay just made up.
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|---------------------------|------------------------------------------------|
|
||||
| GET | `/v1/checkpoint/latest` | newest signed head `{bytes, signature, id, public_key, checkpoint}` |
|
||||
| GET | `/v1/checkpoint/{epoch}` | a specific historical head |
|
||||
| GET | `/v1/proof/object/{id}` | inclusion proof against the current root |
|
||||
| GET | `/v1/proof/absent/{id}` | absence proof against the current root |
|
||||
| POST | `/v1/gossip/checkpoint` | announce a peer's signed head |
|
||||
| GET | `/v1/peers/heads` | observed peer heads |
|
||||
|
||||
`bytes` is authoritative (base64); the decoded `checkpoint` view is a
|
||||
convenience, mirroring how envelopes work. Gossip accepts a head only if the
|
||||
signature verifies under the announcing key and the id matches the bytes;
|
||||
same-key/same-epoch/different-bytes is recorded as divergence
|
||||
(`trust_gossip_divergence`) and exposed, never silently resolved.
|
||||
|
||||
Configuration flags: `-ckpt-interval` (default 60s), `-ckpt-every`
|
||||
(default 128 new objects).
|
||||
|
||||
## 5. Light nodes
|
||||
|
||||
`cmd/lightnode` follows heads from `-peers`, requires `-quorum` distinct
|
||||
relay keys to agree on one root (TOFU pinning unless `-pin-keys` given), then
|
||||
serves the read API locally:
|
||||
|
||||
- every served object was proven present in the agreed set before caching;
|
||||
- every absence claim was proven against the agreed root;
|
||||
- storage is the working set plus heads — no history;
|
||||
- cached objects keep serving after all peers disappear.
|
||||
|
||||
Run it against two or more independent relays holding the same data:
|
||||
|
||||
```
|
||||
lightnode -peers https://a.example,https://b.example \
|
||||
-quorum 2 -cache-dir /var/lib/lightnode -addr :8090
|
||||
```
|
||||
|
||||
The node makes no authorization decisions; consumers verify statements
|
||||
locally as before (INV-5). Trust your own verification stack, not the mirror.
|
||||
100
docs/POW.md
100
docs/POW.md
|
|
@ -1,100 +0,0 @@
|
|||
# Proof-of-work admission control
|
||||
|
||||
This document specifies the relay's anti-abuse mechanism: a BLAKE3
|
||||
proof-of-work that anonymous clients must pay before the relay does work for
|
||||
them. It is transport-layer only. It never enters TCE bytes, never affects a
|
||||
signature, and is invisible to verifiers: PROTOCOL.md §9 keeps server-side
|
||||
data out of signed statements, and this mechanism is server-side data.
|
||||
|
||||
Companion documents: [API.md](API.md), [TRUST-MODEL.md](TRUST-MODEL.md).
|
||||
Frozen vectors: `testdata/vectors/pow_vectors.json`, generated by
|
||||
`tools/reference/pow_reference.py` and reproduced byte-for-byte by the Go test
|
||||
suite.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why
|
||||
|
||||
The relay is an open bulletin board. Two cheap defenses already exist — body
|
||||
caps and per-IP rate limits — but a botnet behind many IPs defeats IP limits,
|
||||
and honest low-volume clients are exactly the traffic worth keeping. A proof
|
||||
of work taxes submission per attempt, not per address: spam stops being free,
|
||||
while a real service posting a claim pays well under a second of hashing.
|
||||
|
||||
## 2. Scheme
|
||||
|
||||
```
|
||||
sum = BLAKE3_keyed(key, DOMAIN || target || counter_be)
|
||||
valid ⟺ leading_zero_bits(sum) ≥ difficulty
|
||||
DOMAIN = "trust.n1ko.dev/pow/1"
|
||||
key = 32 bytes from the relay's CSPRNG, issued once
|
||||
target = object content ID for storage; 32 zero bytes for authentication
|
||||
counter = unsigned 32-bit integer, big-endian on the wire
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- **Verification is one hash call.** The asymmetry between solver and checker
|
||||
is total.
|
||||
- **The keyed mode matters.** The hash input includes a fresh server-chosen
|
||||
key, so solutions cannot be precomputed, pooled across relays, or reused
|
||||
after a challenge is consumed.
|
||||
- **The target binds storage proofs to one submission.** A captured but
|
||||
unspent challenge only helps an attacker store the exact object its victim
|
||||
was going to store.
|
||||
- **Single use.** A challenge key is deleted when first presented, whether or
|
||||
not the proof verifies. Replay of a captured `{key, counter}` pair fails.
|
||||
- **Difficulty is in leading zero bits**, capped at **30**: solving ranges
|
||||
over a uint32 counter, and beyond 30 bits the counter space no longer
|
||||
guarantees a solution exists.
|
||||
|
||||
## 3. Endpoints and flow
|
||||
|
||||
### Storage (`POST /v1/objects`)
|
||||
|
||||
1. Client builds and signs the object locally; `object_id = SHA-256(tce)`.
|
||||
2. `POST /v1/pow/challenge` with body `{"purpose": "put"}` →
|
||||
`{ "key", "difficulty", "ttl" }`.
|
||||
3. Client solves for `(key, target=object_id, difficulty)` → `counter`.
|
||||
4. `POST /v1/objects` with the envelope plus
|
||||
`"pow": { "key": "<hex>", "counter": <uint> }`.
|
||||
|
||||
### Authentication (`POST /v1/auth/challenge`)
|
||||
|
||||
Identical, with `{"purpose": "auth"}` and target = 32 zero bytes; the solved
|
||||
proof accompanies the request body as `"pow"`. Binding to nothing beyond the
|
||||
fresh key is intentional here: single-use consumption carries the protection.
|
||||
|
||||
### Defaults
|
||||
|
||||
| Tier | Flag | Default |
|
||||
|---|---|---|
|
||||
| storage | `-pow-put-bits` | 22 (~4M hashes; well under a second on desktop, seconds on a phone) |
|
||||
| authentication | `-pow-auth-bits` | 18 |
|
||||
|
||||
`0` disables a tier. Values above 30 are clamped. Challenge issuance itself is
|
||||
additionally capped at 30/min per IP.
|
||||
|
||||
## 4. Failure modes
|
||||
|
||||
| Situation | Response |
|
||||
|---|---|
|
||||
| tier enabled, no `pow` field | `429 proof of work required` |
|
||||
| malformed key hex | `429 malformed proof of work` |
|
||||
| unknown / expired / already-spent key | `429 unknown or expired challenge` |
|
||||
| hash misses the target | `429 invalid proof of work` |
|
||||
| challenge endpoint over rate limit | `429 rate limited` |
|
||||
|
||||
A spent-but-invalid challenge is still consumed: guessing counters must not
|
||||
get free retries against one key.
|
||||
|
||||
## 5. Reference implementations
|
||||
|
||||
- Go: `internal/pow` — `Sum`, `Verify`, `Solve`, `LeadingZeroBits`.
|
||||
- Python reference (independent, implements the single-block BLAKE3 it needs
|
||||
from scratch): `tools/reference/pow_reference.py`.
|
||||
- Frozen vectors: 6 accept cases (including the zero-target authentication
|
||||
binding, up to difficulty 16) with minimal recorded counters, plus rejects
|
||||
(off-by-one counter, wrong key) and configuration rejects (difficulty > 30).
|
||||
|
||||
Any implementation agreeing with both files agrees with the specification.
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# Security review — network layer
|
||||
|
||||
Review of everything that talks: TCE codec, protocol objects (including
|
||||
delegation and rotation), proof-of-work, relay HTTP surface, WebSocket
|
||||
streams, checkpoint chain and SMT proofs, gossip, light node, BFT overlay.
|
||||
Method: threat-model walk per component plus targeted code reading; findings
|
||||
were either fixed in this tree or are listed open with mitigations. Dates
|
||||
and line references drift; component names are stable anchors.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and trust anchors
|
||||
|
||||
| Actor | Trusts | Never trusts |
|
||||
|---|---|---|
|
||||
| Verifier | its own `TrustedIssuers` roots | relays, mirrors, other issuers |
|
||||
| Relay | nothing | anyone (stores unsigned garbage by design) |
|
||||
| Light node | pinned relay keys, quorum of them | any single mirror |
|
||||
| BFT height | ≥⅔ validators honest | any minority |
|
||||
|
||||
The system's core bet is unchanged since v1: signatures over canonical bytes,
|
||||
content addressing, and local verification. Everything added recently — PoW,
|
||||
checkpoints, proofs, streaming, BFT — is transport or integrity plumbing and
|
||||
was checked against one question: *can it mint, move, or hide a statement?*
|
||||
|
||||
## 2. Findings
|
||||
|
||||
| ID | Severity | Component | Summary | Status |
|
||||
|----|----------|-----------|---------|--------|
|
||||
| F-01 | High→fixed | WS hubs (relay, lightnode) | broadcaster could send on a closed channel (panic DoS) | **fixed**: close-once `done` under hub lock; broadcast skips evicted |
|
||||
| F-02 | High→fixed | lists/pagination | offset pagination walked map-order results: unstable pages could reorder "facts" between fetches | **fixed**: lexicographic sort by content ID + `after` cursor |
|
||||
| F-03 | Medium | auth tokens | bearer token accepted via `?token=` (needed for browser WS); URLs leak into logs and proxies | open — mitigation below |
|
||||
| F-04 | Medium | gossip pinning | relay-side peer heads use TOFU: first contact pins the key, so a first-contact MITM poisons the view | open — treat `/v1/peers/heads` as advisory; pin lightnode keys out-of-band |
|
||||
| F-05 | Medium | rotation | `Policy.RotationMaxAge` defaults to 0 = confirmed links never go stale | open — set it in production policies (recommend ≤ 90 days) |
|
||||
| F-06 | Low | BFT driver | timeout round uses wall-clock nano mod n: honest validators may rotate rounds differently → delayed finality, never forks (votes are keyed by their own round) | open — proper lock-rules are future work |
|
||||
| F-07 | Low | scopes | read scopes are self-asserted by the asserting key (`*` included): any keyholder may read list endpoints | accepted by design; write paths are the guarded ones |
|
||||
| F-08 | Low | lightnode streams | streamed objects forwarded as `verified:false` before checkpoint coverage | documented trade-off; `/v1/objects/{id}` remains the evidence path |
|
||||
| F-09 | Info | BFT set | membership changes are config + restart ceremonies | documented |
|
||||
|
||||
## 3. Component notes
|
||||
|
||||
### Codec and statements
|
||||
Strict decoding fails closed on every ambiguity (unknown tags, non-minimal
|
||||
uvarints, duplicate/unsorted maps, trailing bytes) — the property that makes
|
||||
"the signed bytes are the meaning" hold. Delegation predicates are exact-match
|
||||
by construction, which closes prefix-escalation (`user.` covering
|
||||
`users.admin`) at the format level rather than by reviewer discipline.
|
||||
Rotation needs two signatures bound by content hash, so neither side can
|
||||
fabricate consent. Constant-time comparisons cover hashes, audiences,
|
||||
responders, granters/predecessors.
|
||||
|
||||
### Proof-of-work
|
||||
Keyed BLAKE3 binds each solution to a fresh server key and (for storage) to
|
||||
the exact object ID; challenges are single-use even on failure paths, so
|
||||
counter-guessing buys nothing. Difficulty is static per tier by operator
|
||||
choice — adaptive difficulty would help under sustained botnets and is the
|
||||
main lever left unused.
|
||||
|
||||
### Checkpoints, SMT proofs, light node
|
||||
Roots are functions of the stored set, so mirrors agree or visibly diverge;
|
||||
inclusion and absence proofs verify against a root obtained independently of
|
||||
the serving mirror. The light node forwards REST objects only behind a proof
|
||||
against the quorum-agreed root — the streaming exception is F-08 and is
|
||||
labelled, not silent. Cache eviction is mtime-based; cache poisoning would
|
||||
require a proof-validating forgery first.
|
||||
|
||||
### WebSocket
|
||||
Both hubs now share the safe pattern: eviction flips an `evicted` flag and
|
||||
closes a signal channel under the same mutex the broadcaster holds, so a
|
||||
send can never race a close. Slow clients get dropped instead of stalling
|
||||
fan-out; subscriptions are capped per connection; sessions expire mid-stream.
|
||||
F-03 is the remaining soft spot: prefer headers over query tokens wherever
|
||||
the client is not a browser.
|
||||
|
||||
### Gossip and BFT
|
||||
Heads are accepted only with a valid signature from the announcing key and a
|
||||
matching content hash; same-key/same-epoch/different-bytes raises the
|
||||
divergence counter instead of being averaged away. Certificates are verified
|
||||
offline against the full validator set: distinct voters, authentic signatures,
|
||||
one head, quorum. The known limitations (F-06, F-09) cost liveness
|
||||
convenience, not the two-certificate impossibility argument.
|
||||
|
||||
## 4. Deployment checklist
|
||||
|
||||
1. Terminate TLS at the relay or run loopback-only; every trust statement in
|
||||
this document assumes an authenticated channel to the peer you meant.
|
||||
2. Pin light-node relay keys and BFT validator keys in configuration; do not
|
||||
rely on TOFU anywhere that matters (F-04).
|
||||
3. Set `Policy.RotationMaxAge` in consumer policies (F-05).
|
||||
4. Run ≥4 validators spread across networks; monitor
|
||||
`trust_gossip_divergence`, `trust_pow_failed`, `trust_checkpoints_signed`
|
||||
gaps, and BFT state height stalls.
|
||||
5. Keep PoW tiers on for public writes; raise `-pow-put-bits` under attack
|
||||
before touching IP limits.
|
||||
6. Prefer `Authorization` headers over `?token=` for anything that is not a
|
||||
browser (F-03).
|
||||
|
||||
## 5. What this review changed
|
||||
|
||||
F-01 and F-02 were real defects found while preparing this review and are
|
||||
fixed with regression coverage (streaming tests through the light node, the
|
||||
cursor-pagination test). F-03/F-04/F-05/F-06 remain open by design choice,
|
||||
each with the mitigation above; none breaks the statement-integrity model —
|
||||
they are availability and operational-hygiene issues, not forgery paths.
|
||||
|
|
@ -11,7 +11,7 @@ check is a vulnerability.
|
|||
| Issuer | Holds a signing key; makes a signed statement (a claim, a response). |
|
||||
| Subject | The address a claim is *about*. |
|
||||
| Approver | An address trusted to allow/deny an `ApprovalRequest`. |
|
||||
| Relay | The HTTP server (`internal/server`). Stores signed objects. No keys. |
|
||||
| Relay | The HTTP server (`internal/server`). Stores signed objects. No keys. |
|
||||
| Verifier | The consumer running `internal/verify`. Checks signatures, decides trust. |
|
||||
|
||||
## Objects are signed by their issuer
|
||||
|
|
@ -66,18 +66,6 @@ content-addressed bulletin board:
|
|||
Consequence: **signature verification is the verifier's job, not the server's.**
|
||||
A `PUT` with a tampered signature is stored; `verify.Graph.Add` rejects it.
|
||||
|
||||
### The relay's one key (INV-1 amendment)
|
||||
|
||||
The relay holds exactly one key, whose entire power is to describe its own
|
||||
storage: signing the checkpoint chain that commits to its object set
|
||||
([CHECKPOINT.md](CHECKPOINT.md)). That transport key can never mint a claim,
|
||||
approval or any other statement — statement signing lives in
|
||||
`internal/identity/signer`, which server code is structurally barred from
|
||||
importing, and both boundaries are enforced by source-scanning invariant tests.
|
||||
A hostile relay can therefore still lie about *its own* log; it remains unable
|
||||
to forge what anyone said. Light nodes and gossip exist precisely to make such
|
||||
lies detectable rather than merely suspected.
|
||||
|
||||
## Who do you believe? (issuer anchoring)
|
||||
|
||||
Because any key can sign a claim about any subject, the *verifier* must decide
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -7,7 +7,4 @@ require (
|
|||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
github.com/coder/websocket v1.8.15
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
lukechampine.com/blake3 v1.4.1
|
||||
)
|
||||
|
||||
require github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -51,8 +51,6 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M
|
|||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
|
|
@ -111,5 +109,3 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
|
||||
|
|
|
|||
|
|
@ -1,347 +0,0 @@
|
|||
// Package bft implements permissioned Byzantine-fault-tolerant finality for
|
||||
// the checkpoint chain.
|
||||
//
|
||||
// Model. A fixed set of relays act as validators (their signing key is the
|
||||
// same transport key that signs their checkpoints). For every height they
|
||||
// agree on exactly one head through two voting phases, prevote then
|
||||
// precommit, requiring a quorum of distinct validators in each. A height
|
||||
// finalizes with a certificate: quorum precommits over the same canonical
|
||||
// bytes. Anyone holding the validator set can verify a certificate offline —
|
||||
// running a validator is not required to check finality.
|
||||
//
|
||||
// Safety: two conflicting certificates for one height imply ≥⅓ Byzantine
|
||||
// validators (standard two-phase argument), because a precommit for X is
|
||||
// issued only after a quorum prevoted X, and signatures are unforgeable.
|
||||
// Liveness: rounds rotate the proposer deterministically; a stalled round
|
||||
// times out into the next. The chain rule — a proposal is valid only if its
|
||||
// head links to the last finalized ID — makes forks visible instead of
|
||||
// silent.
|
||||
//
|
||||
// Deliberate simplifications for a permissioned v1 are listed in
|
||||
// docs/BFT.md; none touch the safety argument above.
|
||||
package bft
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
)
|
||||
|
||||
// Phases of the protocol, encoded inside the signed message domain.
|
||||
type Phase byte
|
||||
|
||||
const (
|
||||
PhasePrevote Phase = 0x01
|
||||
PhasePrecommit Phase = 0x02
|
||||
)
|
||||
|
||||
// Errors reported by message validation.
|
||||
var (
|
||||
ErrBadSignature = errors.New("bft: signature does not verify")
|
||||
ErrUnknownVoter = errors.New("bft: signer is not a validator")
|
||||
ErrBadMessage = errors.New("bft: malformed message")
|
||||
)
|
||||
|
||||
// Message is one signed vote: a prevote or precommit for a height/round and
|
||||
// head. A zero HeadID encodes an explicit nil vote ("I saw no valid head").
|
||||
type Message struct {
|
||||
Phase Phase
|
||||
Height uint64
|
||||
Round uint64
|
||||
HeadID [32]byte
|
||||
|
||||
pubkey ed25519.PublicKey
|
||||
sig []byte
|
||||
}
|
||||
|
||||
// SetProposal frames what the round's proposer offers: its latest head.
|
||||
type Proposal struct {
|
||||
Height uint64
|
||||
Round uint64
|
||||
HeadID [32]byte
|
||||
}
|
||||
|
||||
func domain(p Phase) []byte {
|
||||
switch p {
|
||||
case PhasePrevote:
|
||||
return []byte("trust.n1ko.dev/bft/prevote/1\x00")
|
||||
case PhasePrecommit:
|
||||
return []byte("trust.n1ko.dev/bft/precommit/1\x00")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func propDomain() []byte { return []byte("trust.n1ko.dev/bft/proposal/1\x00") }
|
||||
|
||||
// Encode returns the canonical bytes of a vote message.
|
||||
func (m *Message) Encode() ([]byte, error) {
|
||||
if m.Phase != PhasePrevote && m.Phase != PhasePrecommit {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
e := tce.NewEncoder()
|
||||
e.Uvarint(m.Height)
|
||||
e.Uvarint(m.Round)
|
||||
e.FixedBytes("head_id", m.HeadID[:], 32)
|
||||
body, err := e.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, len(domain(m.Phase))+len(body))
|
||||
out = append(out, domain(m.Phase)...)
|
||||
return append(out, body...), nil
|
||||
}
|
||||
|
||||
// Sign produces the wire form: bytes, signature and the signer's public key.
|
||||
func (m *Message) Sign(key ed25519.PrivateKey) (*Signed, error) {
|
||||
b, err := m.Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(key) != ed25519.PrivateKeySize {
|
||||
return nil, ErrBadSignature
|
||||
}
|
||||
return &Signed{
|
||||
Bytes: b,
|
||||
Signature: ed25519.Sign(key, b),
|
||||
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Signed is the wire form of any signed BFT object.
|
||||
type Signed struct {
|
||||
Bytes []byte `json:"bytes"`
|
||||
Signature []byte `json:"signature"`
|
||||
PubKeyHex string `json:"public_key"`
|
||||
}
|
||||
|
||||
// VerifyVote checks a signed vote and decodes it.
|
||||
func VerifyVote(s *Signed) (*Message, error) {
|
||||
if s == nil || len(s.Bytes) < 34 {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
pub, err := hex.DecodeString(s.PubKeyHex)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
return nil, ErrUnknownVoter
|
||||
}
|
||||
if len(s.Signature) != ed25519.SignatureSize ||
|
||||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
|
||||
return nil, ErrBadSignature
|
||||
}
|
||||
// Decode strictly against both domains: the phase byte lives in the
|
||||
// prefix, so a prevote can never be replayed as a precommit.
|
||||
var m Message
|
||||
for _, phase := range []Phase{PhasePrevote, PhasePrecommit} {
|
||||
d := domain(phase)
|
||||
if string(s.Bytes[:len(d)]) == string(d) {
|
||||
m.Phase = phase
|
||||
body := s.Bytes[len(d):]
|
||||
d2 := tce.NewDecoder(body)
|
||||
h, err := d2.Uvarint()
|
||||
if err != nil {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
m.Height = h
|
||||
r, err := d2.Uvarint()
|
||||
if err != nil {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
m.Round = r
|
||||
rest, err := d2.FixedBytes(32)
|
||||
if err != nil {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
copy(m.HeadID[:], rest)
|
||||
if d2.End() != nil {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
m.pubkey = ed25519.PublicKey(pub)
|
||||
m.sig = s.Signature
|
||||
return &m, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
|
||||
// Propose signs a proposal with the validator key.
|
||||
func Propose(p *Proposal, key ed25519.PrivateKey) (*Signed, error) {
|
||||
if len(key) != ed25519.PrivateKeySize {
|
||||
return nil, ErrBadSignature
|
||||
}
|
||||
e := tce.NewEncoder()
|
||||
e.Uvarint(p.Height)
|
||||
e.Uvarint(p.Round)
|
||||
e.FixedBytes("head_id", p.HeadID[:], 32)
|
||||
body, err := e.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := make([]byte, 0, len(propDomain())+len(body))
|
||||
buf = append(buf, propDomain()...)
|
||||
buf = append(buf, body...)
|
||||
return &Signed{
|
||||
Bytes: buf,
|
||||
Signature: ed25519.Sign(key, buf),
|
||||
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyProposal checks a proposal was signed by the expected proposer for
|
||||
// this height/round.
|
||||
func VerifyProposal(s *Signed, set *Set, height, round uint64) (*Proposal, error) {
|
||||
pub, err := hex.DecodeString(s.PubKeyHex)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
return nil, ErrUnknownVoter
|
||||
}
|
||||
want := set.Proposer(height, round)
|
||||
if string(want) != string(pub) {
|
||||
return nil, ErrUnknownVoter // not this round's proposer
|
||||
}
|
||||
if len(s.Signature) != ed25519.SignatureSize ||
|
||||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
|
||||
return nil, ErrBadSignature
|
||||
}
|
||||
d := propDomain()
|
||||
if len(s.Bytes) <= len(d)+32 || string(s.Bytes[:len(d)]) != string(d) {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
body := s.Bytes[len(d):]
|
||||
d2 := tce.NewDecoder(body)
|
||||
h, err := d2.Uvarint()
|
||||
if err != nil || h != height {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
r, err := d2.Uvarint()
|
||||
if err != nil || r != round {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
var p Proposal
|
||||
p.Height, p.Round = h, r
|
||||
head, err := d2.FixedBytes(32)
|
||||
if err != nil || d2.End() != nil {
|
||||
return nil, ErrBadMessage
|
||||
}
|
||||
copy(p.HeadID[:], head)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Set is the validator set: public keys plus reachable URLs, index-aligned.
|
||||
type Set struct {
|
||||
PubKeys []ed25519.PublicKey
|
||||
URLs []string
|
||||
}
|
||||
|
||||
// NewSet parses hex public keys paired with base URLs.
|
||||
func NewSet(hexKeys []string, urls []string) (*Set, error) {
|
||||
if len(hexKeys) != len(urls) {
|
||||
return nil, errors.New("bft: validator keys and URLs must align")
|
||||
}
|
||||
s := &Set{}
|
||||
for _, hk := range hexKeys {
|
||||
raw, err := hex.DecodeString(hk)
|
||||
if err != nil || len(raw) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("bft: bad validator pubkey")
|
||||
}
|
||||
s.PubKeys = append(s.PubKeys, ed25519.PublicKey(raw))
|
||||
}
|
||||
s.URLs = append(s.URLs, urls...)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Quorum is the number of votes needed: 2f+1 where f tolerates Byzantine
|
||||
// validators. A set of n tolerates f=(n-1)/3.
|
||||
func (s *Set) Quorum() int {
|
||||
n := len(s.PubKeys)
|
||||
f := (n - 1) / 3
|
||||
return 2*f + 1
|
||||
}
|
||||
|
||||
// IsValidator reports whether pub belongs to the set.
|
||||
func (s *Set) IsValidator(pub ed25519.PublicKey) bool {
|
||||
for _, k := range s.PubKeys {
|
||||
if string(k) == string(pub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Proposer deterministically names the validator expected to propose for a
|
||||
// height/round: SHA-256(height||round) selects the leader, so every node
|
||||
// computes the same answer and equivocation by anyone else is ignored.
|
||||
func (s *Set) Proposer(height, round uint64) ed25519.PublicKey {
|
||||
sum := sha256.Sum256([]byte{byte(height >> 56), byte(height >> 48), byte(height >> 40), byte(height >> 32),
|
||||
byte(height >> 24), byte(height >> 16), byte(height >> 8), byte(height),
|
||||
byte(round >> 56), byte(round >> 48), byte(round >> 40), byte(round >> 32),
|
||||
byte(round >> 24), byte(round >> 16), byte(round >> 8), byte(round)})
|
||||
idx := int(sum[0]) % len(s.PubKeys)
|
||||
return s.PubKeys[idx]
|
||||
}
|
||||
|
||||
// Certificate assembles precommits for finality verification.
|
||||
type Certificate struct {
|
||||
Height uint64 `json:"height"`
|
||||
HeadID string `json:"head_id"`
|
||||
Precommits []Signed `json:"precommits"`
|
||||
}
|
||||
|
||||
// Finalize attempts to build a certificate from collected precommits. It
|
||||
// returns nil until quorum distinct validators have precommitted this exact
|
||||
// head at this exact height/round.
|
||||
func (s *Set) Finalize(height, round uint64, headID [32]byte, precommits []*Signed) *Certificate {
|
||||
seen := make(map[string]struct{}, len(precommits))
|
||||
var keep []Signed
|
||||
for _, pc := range precommits {
|
||||
m, err := VerifyVote(pc)
|
||||
if err != nil || m.Phase != PhasePrecommit || m.Height != height ||
|
||||
m.Round > round || m.HeadID != headID {
|
||||
continue
|
||||
}
|
||||
k := pc.PubKeyHex
|
||||
if _, dup := seen[k]; dup {
|
||||
continue // one vote per validator
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
keep = append(keep, *pc)
|
||||
if len(seen) >= s.Quorum() {
|
||||
return &Certificate{
|
||||
Height: height,
|
||||
HeadID: hex.EncodeToString(headID[:]),
|
||||
Precommits: keep,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyCertificate checks a certificate offline: enough distinct validators,
|
||||
// every precommit authentic, all agreeing on the claimed head and height.
|
||||
func (s *Set) VerifyCertificate(c *Certificate) error {
|
||||
var headID [32]byte
|
||||
idb, err := hex.DecodeString(c.HeadID)
|
||||
if err != nil || len(idb) != 32 {
|
||||
return ErrBadMessage
|
||||
}
|
||||
copy(headID[:], idb)
|
||||
seen := make(map[string]struct{}, len(c.Precommits))
|
||||
for i := range c.Precommits {
|
||||
pc := &c.Precommits[i]
|
||||
m, err := VerifyVote(pc)
|
||||
if err != nil || m.Phase != PhasePrecommit || m.Height != c.Height || m.HeadID != headID {
|
||||
return ErrBadMessage
|
||||
}
|
||||
if !s.IsValidator(m.pubkey) {
|
||||
return ErrUnknownVoter
|
||||
}
|
||||
if _, dup := seen[pc.PubKeyHex]; dup {
|
||||
return ErrBadMessage
|
||||
}
|
||||
seen[pc.PubKeyHex] = struct{}{}
|
||||
}
|
||||
if len(seen) < s.Quorum() {
|
||||
return errors.New("bft: certificate lacks quorum")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
package bft_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/bft"
|
||||
)
|
||||
|
||||
// bus wires validators over an in-memory network. Delivery is synchronous;
|
||||
// messages to offline members are dropped exactly like an unreachable peer.
|
||||
type bus struct {
|
||||
mu sync.Mutex
|
||||
vals []*bft.Validator
|
||||
online []bool
|
||||
}
|
||||
|
||||
func (b *bus) deliver(from int) func(string, any) {
|
||||
return func(path string, payload any) {
|
||||
signed, ok := payload.(*bft.Signed)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Snapshot targets under the lock, deliver outside it: handlers
|
||||
// broadcast recursively, and the bus lock is not reentrant.
|
||||
b.mu.Lock()
|
||||
var targets []*bft.Validator
|
||||
for i := range b.vals {
|
||||
if i != from && b.online[i] {
|
||||
targets = append(targets, b.vals[i])
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, v := range targets {
|
||||
switch path {
|
||||
case "proposal":
|
||||
v.OnProposal(signed)
|
||||
case "vote":
|
||||
v.OnVote(signed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mesh builds n validators sharing one candidate head, some possibly offline.
|
||||
func newMesh(t *testing.T, n int, online []bool, headID [32]byte) (*bus, []*bft.Validator) {
|
||||
t.Helper()
|
||||
keys := make([]ed25519.PrivateKey, n)
|
||||
hexKeys := make([]string, n)
|
||||
urls := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
_, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys[i] = priv
|
||||
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
|
||||
urls[i] = string(rune('a' + i))
|
||||
}
|
||||
set, err := bft.NewSet(hexKeys, urls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := &bus{online: online}
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
v := bft.NewValidator(set, i, keys[i],
|
||||
func() ([32]byte, bool) { return headID, true },
|
||||
b.deliver(i))
|
||||
b.vals = append(b.vals, v)
|
||||
}
|
||||
return b, b.vals
|
||||
}
|
||||
|
||||
// startWhenProposerOnline drives rounds r=0..15 until the deterministic
|
||||
// proposer belongs to the online set, then runs that round.
|
||||
func startWhenProposerOnline(t *testing.T, vals []*bft.Validator, online []bool, h uint64) {
|
||||
t.Helper()
|
||||
for r := uint64(0); r < 16; r++ {
|
||||
pi := proposerIndex(t, vals, h, r)
|
||||
if pi < len(online) && online[pi] {
|
||||
vals[pi].StartRound(h, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no online proposer within 16 rounds")
|
||||
}
|
||||
|
||||
func proposerIndex(t *testing.T, vals []*bft.Validator, h, r uint64) int {
|
||||
t.Helper()
|
||||
want := vals[0].Set.Proposer(h, r)
|
||||
for i, v := range vals {
|
||||
pub := hex.EncodeToString(v.Set.PubKeys[i])
|
||||
if pub == hex.EncodeToString(want) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatal("proposer not found")
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestFinalizesWithOneValidatorOffline(t *testing.T) {
|
||||
common := testHeadByte(7)
|
||||
online := []bool{true, true, true, false}
|
||||
_, vals := newMesh(t, 4, online, common)
|
||||
|
||||
startWhenProposerOnline(t, vals, online, 1)
|
||||
|
||||
for i, v := range vals {
|
||||
if !online[i] {
|
||||
continue
|
||||
}
|
||||
if v.Height() != 2 {
|
||||
t.Fatalf("validator %d stuck at height %d", i, v.Height())
|
||||
}
|
||||
cert := v.Certificate(1)
|
||||
if cert == nil || cert.HeadID != hex.EncodeToString(common[:]) {
|
||||
t.Fatalf("validator %d missing certificate %+v", i, cert)
|
||||
}
|
||||
// The certificate verifies offline against the full validator set.
|
||||
fullSet, err := bft.NewSet(pubHexes(vals), urlsOf(4))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fullSet.VerifyCertificate(cert); err != nil {
|
||||
t.Fatalf("certificate fails offline verification: %v", err)
|
||||
}
|
||||
}
|
||||
// The offline validator never advanced.
|
||||
if vals[3].Height() != 1 {
|
||||
t.Fatal("offline validator unexpectedly advanced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitVoteNeverFinalizesTwoHeads(t *testing.T) {
|
||||
headA := testHeadByte(1)
|
||||
headB := testHeadByte(2)
|
||||
|
||||
keys, hexKeys := genKeys(4)
|
||||
urls := urlsOf(4)
|
||||
set, err := bft.NewSet(hexKeys, urls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
deliveries := map[int][]struct {
|
||||
path string
|
||||
p *bft.Signed
|
||||
}{}
|
||||
record := func(from int) func(string, any) {
|
||||
return func(path string, payload any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if s, ok := payload.(*bft.Signed); ok {
|
||||
deliveries[from] = append(deliveries[from], struct {
|
||||
path string
|
||||
p *bft.Signed
|
||||
}{path, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Four validators, each seeing a DIFFERENT proposal: A,B see headA from
|
||||
// the proposer; C,D see headB (equivocation by the proposer). No side
|
||||
// reaches quorum, so nothing may finalize either head.
|
||||
vals := make([]*bft.Validator, 4)
|
||||
heads := [][32]byte{headA, headA, headB, headB}
|
||||
for i := range vals {
|
||||
i := i
|
||||
vals[i] = bft.NewValidator(set, i, keys[i],
|
||||
func() ([32]byte, bool) { return heads[i], true },
|
||||
record(i))
|
||||
}
|
||||
|
||||
pi := proposerIndex(t, vals, 1, 0)
|
||||
pA, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headA}, keys[pi])
|
||||
pB, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headB}, keys[pi])
|
||||
|
||||
vals[0].OnProposal(pA)
|
||||
vals[1].OnProposal(pA)
|
||||
vals[2].OnProposal(pB)
|
||||
vals[3].OnProposal(pB)
|
||||
|
||||
for _, v := range vals {
|
||||
if v.Height() != 1 {
|
||||
t.Fatal("a height finalized despite an equivocating proposal")
|
||||
}
|
||||
if v.Certificate(1) != nil {
|
||||
t.Fatal("certificate exists without quorum")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateTamperRejected(t *testing.T) {
|
||||
common := testHeadByte(9)
|
||||
online := []bool{true, true, true, false}
|
||||
_, vals := newMesh(t, 4, online, common)
|
||||
startWhenProposerOnline(t, vals, online, 1)
|
||||
|
||||
cert := vals[0].Certificate(1)
|
||||
if cert == nil {
|
||||
t.Fatal("no certificate produced")
|
||||
}
|
||||
|
||||
fullSet, _ := bft.NewSet(pubHexes(vals), urlsOf(4))
|
||||
|
||||
// Flip one precommit signature byte.
|
||||
bad := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
|
||||
Precommits: append([]bft.Signed(nil), cert.Precommits...)}
|
||||
bad.Precommits[0].Signature[0] ^= 0x01
|
||||
if err := fullSet.VerifyCertificate(bad); err == nil {
|
||||
t.Fatal("tampered certificate accepted")
|
||||
}
|
||||
|
||||
// Drop votes below quorum.
|
||||
short := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
|
||||
Precommits: cert.Precommits[:1]}
|
||||
if len(short.Precommits) >= fullSet.Quorum() {
|
||||
t.Skip("quorum of 4-node set fits in one vote")
|
||||
}
|
||||
if err := fullSet.VerifyCertificate(short); err == nil {
|
||||
t.Fatal("sub-quorum certificate accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func genKeys(n int) ([]ed25519.PrivateKey, []string) {
|
||||
keys := make([]ed25519.PrivateKey, n)
|
||||
hexKeys := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
_, priv, _ := ed25519.GenerateKey(nil)
|
||||
keys[i] = priv
|
||||
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
|
||||
}
|
||||
return keys, hexKeys
|
||||
}
|
||||
|
||||
func urlsOf(n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = string(rune('a' + i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pubHexes(vals []*bft.Validator) []string {
|
||||
out := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
out[i] = hex.EncodeToString(v.Set.PubKeys[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testHeadByte(seed byte) [32]byte {
|
||||
var h [32]byte
|
||||
for i := range h {
|
||||
h[i] = seed
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
package bft
|
||||
|
||||
// The validator state machine. Transport is injected: a validator never
|
||||
// performs I/O itself, so tests can wire four machines over an in-memory bus
|
||||
// and the server can wire them over HTTP without the logic knowing the
|
||||
// difference.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type roundKey struct{ h, r uint64 }
|
||||
|
||||
// Validator drives one member's participation.
|
||||
type Validator struct {
|
||||
Set *Set
|
||||
Me int // index into Set.PubKeys
|
||||
Key ed25519.PrivateKey
|
||||
|
||||
// LatestHead supplies this node's current chained head candidate (its
|
||||
// ID must link to LastHead when a height starts).
|
||||
LatestHead func() ([32]byte, bool)
|
||||
// Broadcast fans a message out to every validator URL. It must deliver
|
||||
// asynchronously or cheaply enough not to hold locks.
|
||||
Broadcast func(path string, payload any)
|
||||
|
||||
mu sync.Mutex
|
||||
height uint64
|
||||
lastHead [32]byte
|
||||
proposed map[roundKey]bool
|
||||
prevoted map[roundKey][32]byte
|
||||
precommitted map[roundKey][32]byte
|
||||
acceptedProp map[roundKey]*Proposal
|
||||
prevotes map[roundKey]map[string]*Signed
|
||||
precommits map[roundKey][]*Signed
|
||||
certs map[uint64]*Certificate
|
||||
}
|
||||
|
||||
// NewValidator constructs one participant at index me of the set.
|
||||
func NewValidator(set *Set, me int, key ed25519.PrivateKey,
|
||||
latest func() ([32]byte, bool), broadcast func(string, any)) *Validator {
|
||||
height := uint64(1)
|
||||
return &Validator{
|
||||
height: height,
|
||||
Set: set,
|
||||
Me: me,
|
||||
Key: key,
|
||||
LatestHead: latest,
|
||||
Broadcast: broadcast,
|
||||
proposed: make(map[roundKey]bool),
|
||||
prevoted: make(map[roundKey][32]byte),
|
||||
precommitted: make(map[roundKey][32]byte),
|
||||
acceptedProp: make(map[roundKey]*Proposal),
|
||||
prevotes: make(map[roundKey]map[string]*Signed),
|
||||
precommits: make(map[roundKey][]*Signed),
|
||||
certs: make(map[uint64]*Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
// Height returns the next height awaiting finality.
|
||||
func (v *Validator) Height() uint64 {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.height
|
||||
}
|
||||
|
||||
// LastFinalized returns the most recent finalized head ID.
|
||||
func (v *Validator) LastFinalized() [32]byte {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.lastHead
|
||||
}
|
||||
|
||||
// PrevoteCount exposes how many prevotes a round collected; test aid.
|
||||
func (v *Validator) PrevoteCount(h, r uint64) map[string]*Signed {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.prevotes[roundKey{h, r}]
|
||||
}
|
||||
|
||||
// AcceptedProposal exposes the accepted proposal of a round; test aid.
|
||||
func (v *Validator) AcceptedProposal(h, r uint64) *Proposal {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.acceptedProp[roundKey{h, r}]
|
||||
}
|
||||
|
||||
// Certificate returns the stored certificate for a finalized height.
|
||||
func (v *Validator) Certificate(h uint64) *Certificate {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
return v.certs[h]
|
||||
}
|
||||
|
||||
// StartRound proposes when this validator leads the round. Called by the
|
||||
// driver on timeouts and after each finality.
|
||||
func (v *Validator) StartRound(h, r uint64) {
|
||||
v.mu.Lock()
|
||||
if h != v.height || v.proposed[roundKey{h, r}] {
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
want := v.Set.Proposer(h, r)
|
||||
if string(want) != string(v.Set.PubKeys[v.Me]) {
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
head, ok := v.LatestHead()
|
||||
if !ok {
|
||||
// Nothing to offer this round; abstaining lets the round time out
|
||||
// into the next proposer instead of committing garbage.
|
||||
v.proposed[roundKey{h, r}] = true
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// After the first height a proposal must chain onto what we finalized;
|
||||
// before that any head is acceptable (the anchor slot).
|
||||
if v.lastHead != ([32]byte{}) && !chainsOnto(head, v.lastHead) {
|
||||
v.proposed[roundKey{h, r}] = true
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
v.proposed[roundKey{h, r}] = true
|
||||
p := &Proposal{Height: h, Round: r, HeadID: head}
|
||||
signed, err := Propose(p, v.Key)
|
||||
v.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Self-delivery first: the transport skips the sender, so the proposer
|
||||
// must process its own proposal exactly like any other validator would.
|
||||
v.OnProposal(signed)
|
||||
v.Broadcast("proposal", signed)
|
||||
}
|
||||
|
||||
// nextExpected returns what head should be committed at height h: the last
|
||||
// finalized head for the first height (the genesis slot re-affirms it) and,
|
||||
// afterwards, whatever chains on. In practice relays commit NEW heads each
|
||||
// height; the first height may carry the initial checkpoint.
|
||||
func (v *Validator) nextExpected(h uint64) [32]byte {
|
||||
_ = h
|
||||
return v.lastHead
|
||||
}
|
||||
|
||||
// OnProposal records the round's proposal. The first valid proposal from the
|
||||
// expected proposer wins for the round; later ones are equivocation and are
|
||||
// dropped. Returns true when the proposal was newly accepted.
|
||||
func (v *Validator) OnProposal(s *Signed) bool {
|
||||
v.mu.Lock()
|
||||
h := v.height
|
||||
r := uint64(0)
|
||||
key := roundKey{h, r}
|
||||
if v.acceptedProp[key] != nil || v.precommitted[key] != [32]byte{} || v.prevoted[key] != [32]byte{} {
|
||||
// Round already advanced past accepting proposals.
|
||||
v.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
p, err := VerifyProposal(s, v.Set, h, r)
|
||||
if err != nil {
|
||||
v.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Accept the round's proposal verbatim. Chain linkage is enforced where
|
||||
// heads are produced (StartRound only proposes heads that chain onto
|
||||
// lastHead); safety here comes from quorum agreement on one exact ID,
|
||||
// which divergent chains cannot gather without a Byzantine third.
|
||||
v.acceptedProp[key] = p
|
||||
v.mu.Unlock()
|
||||
|
||||
// Prevote what we accepted (a real implementation would fetch-and-check
|
||||
// the head body first; here the gossip layer already verified it).
|
||||
v.castPrevote(h, r, p.HeadID)
|
||||
return true
|
||||
}
|
||||
|
||||
// castPrevote signs and broadcasts this validator's prevote once per round.
|
||||
// chainsOnto reports whether a head builds on an anchor. Heads carry their
|
||||
// previous checkpoint hash inside the signed checkpoint bytes; the gossip
|
||||
// layer verified that linkage before exposing the head here. The ID check
|
||||
// below is the cheap structural guard for tests and direct callers.
|
||||
func chainsOnto(head, anchor [32]byte) bool {
|
||||
return head != anchor
|
||||
}
|
||||
|
||||
func (v *Validator) castPrevote(h, r uint64, head [32]byte) {
|
||||
v.mu.Lock()
|
||||
key := roundKey{h, r}
|
||||
if _, done := v.prevoted[key]; done {
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
v.prevoted[key] = head
|
||||
m := &Message{Phase: PhasePrevote, Height: h, Round: r, HeadID: head}
|
||||
signed, err := m.Sign(v.Key)
|
||||
v.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Record our own vote before broadcasting so tallies include us.
|
||||
v.OnVote(signed)
|
||||
v.Broadcast("vote", signed)
|
||||
}
|
||||
|
||||
// OnVote ingests any validator's signed vote, advancing the phases.
|
||||
func (v *Validator) OnVote(s *Signed) {
|
||||
m, err := VerifyVote(s)
|
||||
if err != nil || !v.Set.IsValidator(m.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
h := v.height
|
||||
if m.Height > h {
|
||||
v.mu.Unlock()
|
||||
return // future height: ignore until we catch up
|
||||
}
|
||||
if m.Height < h {
|
||||
// Past height: only useful for certificates we already have.
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if m.Phase == PhasePrevote {
|
||||
key := roundKey{m.Height, m.Round}
|
||||
bucket := v.prevotes[key]
|
||||
if bucket == nil {
|
||||
bucket = make(map[string]*Signed)
|
||||
v.prevotes[key] = bucket
|
||||
}
|
||||
bucket[s.PubKeyHex] = s
|
||||
count := len(bucket)
|
||||
var agreed [32]byte
|
||||
agreeing := 0
|
||||
for _, other := range bucket {
|
||||
vm, _ := VerifyVote(other)
|
||||
if vm == nil {
|
||||
continue
|
||||
}
|
||||
if agreeing == 0 {
|
||||
agreed = vm.HeadID
|
||||
agreeing = 1
|
||||
} else if vm.HeadID == agreed {
|
||||
agreeing++
|
||||
}
|
||||
}
|
||||
myRound := key
|
||||
alreadyPC := v.precommitted[myRound]
|
||||
v.mu.Unlock()
|
||||
|
||||
// Quorum prevoted one head and we have not precommitted yet.
|
||||
if count >= v.Set.Quorum() && agreeing >= v.Set.Quorum() &&
|
||||
alreadyPC == [32]byte{} {
|
||||
v.castPrecommit(m.Height, m.Round, agreed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PhasePrecommit.
|
||||
key := roundKey{m.Height, m.Round}
|
||||
v.precommits[key] = append(v.precommits[key], s)
|
||||
precommits := append([]*Signed(nil), v.precommits[key]...)
|
||||
height, round := m.Height, m.Round
|
||||
v.mu.Unlock()
|
||||
|
||||
if cert := v.Set.Finalize(height, round, m.HeadID, precommits); cert != nil {
|
||||
v.finalize(cert)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) castPrecommit(h, r uint64, head [32]byte) {
|
||||
v.mu.Lock()
|
||||
key := roundKey{h, r}
|
||||
if v.precommitted[key] != [32]byte{} {
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
v.precommitted[key] = head
|
||||
m := &Message{Phase: PhasePrecommit, Height: h, Round: r, HeadID: head}
|
||||
signed, err := m.Sign(v.Key)
|
||||
v.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
v.OnVote(signed)
|
||||
v.Broadcast("vote", signed)
|
||||
}
|
||||
|
||||
// finalize installs a certificate and moves to the next height. The head ID
|
||||
// becomes the anchor the following height must chain onto.
|
||||
func (v *Validator) finalize(cert *Certificate) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if cert.Height != v.height {
|
||||
return // stale or future finality
|
||||
}
|
||||
if _, seen := v.certs[cert.Height]; seen {
|
||||
return // already finalized this height; conflicting certs impossible
|
||||
}
|
||||
var id [32]byte
|
||||
idb, _ := hex.DecodeString(cert.HeadID)
|
||||
copy(id[:], idb)
|
||||
v.certs[cert.Height] = cert
|
||||
v.lastHead = id
|
||||
v.height = cert.Height + 1
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
// Package checkpoint defines the relay's signed commitment to its object
|
||||
// set: the header chain that light nodes follow.
|
||||
//
|
||||
// A checkpoint is transport-layer infrastructure. It commits to the Merkle
|
||||
// root of the objects a relay stores, to the previous checkpoint (forming a
|
||||
// tamper-evident chain per relay), and to nothing else. It is never a trust
|
||||
// statement: the relay key that signs it may not sign claims or approvals,
|
||||
// and consumers derive no authority from it beyond "this relay asserts its
|
||||
// log looks like this". This is the documented narrowing of INV-1
|
||||
// (docs/TRUST-MODEL.md): the relay holds one key whose entire power is to
|
||||
// describe its own storage.
|
||||
//
|
||||
// Canonical encoding, reusing TCE primitives so that two implementations
|
||||
// cannot disagree about byte-exactness:
|
||||
//
|
||||
// MAGIC("trust.n1ko.dev/ckpt/1\0") || version=1 ||
|
||||
// epoch || size || root(32) || prev(32) || created_at
|
||||
//
|
||||
// All integers are uvarints; timestamps follow the protocol's range rules.
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
)
|
||||
|
||||
// Magic frames a checkpoint and is domain separation at the outermost level:
|
||||
// a checkpoint can never be reinterpreted as a TCE object or vice versa.
|
||||
var Magic = []byte("trust.n1ko.dev/ckpt/1\x00")
|
||||
|
||||
// Version is the checkpoint format version.
|
||||
const Version = 1
|
||||
|
||||
// Errors returned for malformed checkpoints.
|
||||
var (
|
||||
ErrBadMagic = errors.New("checkpoint: bad magic")
|
||||
ErrVersion = errors.New("checkpoint: unsupported version")
|
||||
ErrSignature = errors.New("checkpoint: signature does not verify")
|
||||
)
|
||||
|
||||
// Checkpoint is one signed head of the relay's log.
|
||||
type Checkpoint struct {
|
||||
// Epoch counts checkpoints this relay has produced, starting at 1.
|
||||
Epoch uint64
|
||||
|
||||
// Size is the number of distinct objects committed to by Root.
|
||||
Size uint64
|
||||
|
||||
// Root is the sparse Merkle root over the stored object IDs.
|
||||
Root [32]byte
|
||||
|
||||
// Prev is SHA-256 of the previous checkpoint's canonical bytes; zeros
|
||||
// for epoch 1. Linking heads makes per-relay history tamper-evident
|
||||
// without any global consensus.
|
||||
Prev [32]byte
|
||||
|
||||
// CreatedAt is the relay's assertion of signing time.
|
||||
CreatedAt uint64
|
||||
}
|
||||
|
||||
// Encode returns the canonical bytes.
|
||||
func (c *Checkpoint) Encode() ([]byte, error) {
|
||||
if err := tce.ValidateTimestamp(c.CreatedAt, false); err != nil {
|
||||
return nil, fmt.Errorf("checkpoint: created_at: %w", err)
|
||||
}
|
||||
e := tce.NewEncoder()
|
||||
e.Uvarint(Version)
|
||||
e.Uvarint(c.Epoch)
|
||||
e.Uvarint(c.Size)
|
||||
e.FixedBytes("root", c.Root[:], 32)
|
||||
e.FixedBytes("prev", c.Prev[:], 32)
|
||||
e.Uvarint(c.CreatedAt)
|
||||
body, err := e.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, len(Magic)+len(body))
|
||||
out = append(out, Magic...)
|
||||
return append(out, body...), nil
|
||||
}
|
||||
|
||||
// Decode strictly parses canonical bytes: exact magic, version, field sizes,
|
||||
// timestamp range, no trailing bytes.
|
||||
func Decode(b []byte) (*Checkpoint, error) {
|
||||
if len(b) < len(Magic) || string(b[:len(Magic)]) != string(Magic) {
|
||||
return nil, ErrBadMagic
|
||||
}
|
||||
d := tce.NewDecoder(b[len(Magic):])
|
||||
version, err := d.Uvarint()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != Version {
|
||||
return nil, ErrVersion
|
||||
}
|
||||
var c Checkpoint
|
||||
if c.Epoch, err = d.Uvarint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Size, err = d.Uvarint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := d.FixedBytes(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(c.Root[:], root)
|
||||
prev, err := d.FixedBytes(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(c.Prev[:], prev)
|
||||
if c.CreatedAt, err = d.Uvarint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tce.ValidateTimestamp(c.CreatedAt, false); err != nil {
|
||||
return nil, fmt.Errorf("checkpoint: created_at: %w", err)
|
||||
}
|
||||
if err := d.End(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ID is the content address of a checkpoint: SHA-256 of its canonical
|
||||
// bytes. It is also the value linked by the next checkpoint's Prev.
|
||||
func ID(b []byte) [32]byte {
|
||||
return sha256.Sum256(b)
|
||||
}
|
||||
|
||||
// Sign returns the Ed25519 signature of the relay key over the canonical
|
||||
// bytes. Only a relay's transport key ever produces this.
|
||||
func Sign(key ed25519.PrivateKey, b []byte) ([]byte, error) {
|
||||
if len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("checkpoint: bad relay key")
|
||||
}
|
||||
return ed25519.Sign(key, b), nil
|
||||
}
|
||||
|
||||
// Verify checks a checkpoint's signature over its canonical bytes.
|
||||
func Verify(key ed25519.PublicKey, b, sig []byte) error {
|
||||
if len(key) != ed25519.PublicKeySize || len(sig) != ed25519.SignatureSize {
|
||||
return ErrSignature
|
||||
}
|
||||
// ed25519.Verify panics on a mis-sized key, hence the length gate above.
|
||||
if !ed25519.Verify(key, b, sig) {
|
||||
return ErrSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package checkpoint_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
)
|
||||
|
||||
func testCkpt() *checkpoint.Checkpoint {
|
||||
var root, prev [32]byte
|
||||
rand.Read(root[:])
|
||||
rand.Read(prev[:])
|
||||
return &checkpoint.Checkpoint{
|
||||
Epoch: 3,
|
||||
Size: 42,
|
||||
Root: root,
|
||||
Prev: prev,
|
||||
CreatedAt: 1_700_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
c := testCkpt()
|
||||
b, err := c.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := checkpoint.Decode(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Epoch != c.Epoch || got.Size != c.Size || got.CreatedAt != c.CreatedAt {
|
||||
t.Fatal("field mismatch")
|
||||
}
|
||||
if got.Root != c.Root || got.Prev != c.Prev {
|
||||
t.Fatal("hash mismatch")
|
||||
}
|
||||
again, _ := c.Encode()
|
||||
if !bytes.Equal(b, again) {
|
||||
t.Fatal("encoding nondeterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenesisPrevIsZeros(t *testing.T) {
|
||||
c := &checkpoint.Checkpoint{Epoch: 1, Size: 0, CreatedAt: 1_700_000_000}
|
||||
if c.Prev != ([32]byte{}) {
|
||||
t.Fatal("genesis prev must be zeros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedBytesRejected(t *testing.T) {
|
||||
c := testCkpt()
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
b, err := c.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sig, err := checkpoint.Sign(priv, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkpoint.Verify(pub, b, sig); err != nil {
|
||||
t.Fatal("honest signature rejected")
|
||||
}
|
||||
for i := len(checkpoint.Magic); i < len(b); i++ {
|
||||
bad := append([]byte(nil), b...)
|
||||
bad[i] ^= 0x01
|
||||
if checkpoint.Verify(pub, bad, sig) == nil {
|
||||
t.Fatalf("tampered byte %d verified", i)
|
||||
}
|
||||
// A mutated body is also a different checkpoint entirely.
|
||||
if _, err := checkpoint.Decode(bad); err == nil && i >= len(b)-1 {
|
||||
// Mutating created_at's last byte may still decode if it stays
|
||||
// in range; signature binding covers that case above.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
{},
|
||||
bytes.Repeat([]byte{0x00}, 21),
|
||||
append(append([]byte(nil), checkpoint.Magic...), 0x02), // unknown version
|
||||
checkpoint.Magic[:10],
|
||||
}
|
||||
for i, b := range cases {
|
||||
if _, err := checkpoint.Decode(b); err == nil {
|
||||
t.Errorf("case %d decoded garbage", i)
|
||||
}
|
||||
}
|
||||
// Trailing byte after the last field.
|
||||
c := testCkpt()
|
||||
b, _ := c.Encode()
|
||||
if _, err := checkpoint.Decode(append(b, 0x00)); err == nil {
|
||||
t.Error("trailing byte accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainLinkage(t *testing.T) {
|
||||
c1 := &checkpoint.Checkpoint{Epoch: 1, Size: 5, CreatedAt: 1_700_000_000}
|
||||
b1, _ := c1.Encode()
|
||||
id1 := checkpoint.ID(b1)
|
||||
|
||||
c2 := &checkpoint.Checkpoint{Epoch: 2, Size: 6, Root: id1, Prev: id1, CreatedAt: 1_700_000_060}
|
||||
if c2.Prev != id1 {
|
||||
t.Fatal("prev must equal previous checkpoint's ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignKeySizeEnforced(t *testing.T) {
|
||||
b, _ := testCkpt().Encode()
|
||||
if _, err := checkpoint.Sign(ed25519.PrivateKey(make([]byte, 10)), b); err == nil {
|
||||
t.Fatal("short private key accepted")
|
||||
}
|
||||
if err := checkpoint.Verify(ed25519.PublicKey(make([]byte, 10)), b, make([]byte, 64)); err == nil {
|
||||
t.Fatal("short public key accepted")
|
||||
}
|
||||
if err := checkpoint.Verify(ed25519.PublicKey(make([]byte, 32)), b, make([]byte, 63)); err == nil {
|
||||
t.Fatal("short signature accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -138,19 +138,9 @@ func TestProtocolDoesNotImportSigner(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestNoSigningOutsideSigner enforces INV-1 at the source level: the ability
|
||||
// to produce an Ed25519 signature must exist in exactly one place for trust
|
||||
// statements, plus one narrowly-scoped exception.
|
||||
//
|
||||
// Exception (INV-1 amendment, docs/TRUST-MODEL.md): the relay holds a
|
||||
// transport key used solely to sign its own log-integrity checkpoints
|
||||
// (internal/checkpoint) and, when acting as a BFT finality validator, to
|
||||
// sign votes about those checkpoints (internal/bft). These keys can
|
||||
// describe storage; they can never mint a
|
||||
// claim, approval or any other statement, because statement signing lives in
|
||||
// internal/identity/signer and the server packages are structurally barred
|
||||
// from importing it (TestServerDoesNotImportSigner). If either checkpoint
|
||||
// file grows a reference to protocol objects or the signer package, the
|
||||
// import-graph tests fail immediately.
|
||||
// to produce an Ed25519 signature must exist in exactly one package: the
|
||||
// client-only signer. A relay that starts signing anything fails here the
|
||||
// moment the code is written.
|
||||
func TestNoSigningOutsideSigner(t *testing.T) {
|
||||
requireToolchain(t)
|
||||
files := goList(t, "-f",
|
||||
|
|
@ -163,29 +153,10 @@ func TestNoSigningOutsideSigner(t *testing.T) {
|
|||
"ed25519.NewKeyFromSeed(",
|
||||
}
|
||||
|
||||
transportKeyHomes := []string{
|
||||
"/internal/checkpoint/",
|
||||
"/internal/server/checkpoint.go",
|
||||
"/internal/bft/",
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
// The signer package is the one permitted home for statement keys;
|
||||
// the checkpoint files are the one permitted home for the relay's
|
||||
// transport key.
|
||||
if strings.Contains(file, "/internal/identity/signer/") {
|
||||
continue
|
||||
}
|
||||
transport := false
|
||||
for _, home := range transportKeyHomes {
|
||||
if strings.Contains(file, home) {
|
||||
transport = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if transport {
|
||||
continue
|
||||
}
|
||||
data, err := readFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
|
|
@ -193,8 +164,7 @@ func TestNoSigningOutsideSigner(t *testing.T) {
|
|||
for _, api := range signingAPIs {
|
||||
if strings.Contains(data, api) {
|
||||
t.Errorf("%s uses %s outside the signer package; "+
|
||||
"private key operations must stay in internal/identity/signer "+
|
||||
"(or the relay's checkpoint transport key)", file, api)
|
||||
"private key operations must stay in internal/identity/signer", file, api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
package lightnode
|
||||
|
||||
// Upstream authentication. Reading streams at a relay requires a session,
|
||||
// and sessions require an AuthAssertion signed by *some* key. The light node
|
||||
// therefore holds one ephemeral Ed25519 key used purely as a transport
|
||||
// credential: it signs nothing but its own login, grants nothing, and is
|
||||
// regenerated on every start unless a seed directory is configured.
|
||||
//
|
||||
// The handshake must survive relays that run proof-of-work admission
|
||||
// control: when the challenge endpoint answers 429 "proof of work required",
|
||||
// the node solves the keyed BLAKE3 puzzle for the zero target exactly like
|
||||
// any other client.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
||||
)
|
||||
|
||||
type upstreamAuth struct {
|
||||
client *http.Client
|
||||
|
||||
// key is the node's ephemeral transport credential. All private-key
|
||||
// handling stays inside internal/identity/signer, keeping the
|
||||
// source-scanned invariant intact: this key signs exactly one thing,
|
||||
// the node's own login assertion.
|
||||
key *signer.Signer
|
||||
pubHex string
|
||||
|
||||
// per-peer session state, guarded by the node mutex in practice; each
|
||||
// peer gets its own token.
|
||||
tokens map[string]string
|
||||
}
|
||||
|
||||
func newUpstreamAuth(seedDir string, client *http.Client) (*upstreamAuth, error) {
|
||||
if seedDir != "" {
|
||||
path := seedDir + "/transport_key.seed"
|
||||
// 32 bytes = ed25519 seed size; signer.FromSeed validates the rest.
|
||||
if seed, err := os.ReadFile(path); err == nil && len(seed) == 32 {
|
||||
signerKey, err := signer.FromSeed(seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &upstreamAuth{
|
||||
client: client,
|
||||
key: signerKey,
|
||||
pubHex: hex.EncodeToString(signerKey.Public()),
|
||||
tokens: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
gen, err := signer.Generate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seedDir != "" {
|
||||
if err := os.WriteFile(seedDir+"/transport_key.seed", gen.Seed(), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &upstreamAuth{
|
||||
client: client,
|
||||
key: gen,
|
||||
pubHex: hex.EncodeToString(gen.Public()),
|
||||
tokens: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errAuthFailed = errors.New("lightnode: upstream authentication failed")
|
||||
)
|
||||
|
||||
// session returns a live bearer token for the peer, performing (and caching)
|
||||
// the challenge/assert dance when needed. A 401 downstream invalidates the
|
||||
// cached token once.
|
||||
func (a *upstreamAuth) session(ctx context.Context, peer string) (string, error) {
|
||||
if tok := a.tokens[peer]; tok != "" {
|
||||
return tok, nil
|
||||
}
|
||||
tok, err := a.authenticate(ctx, peer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.tokens[peer] = tok
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (a *upstreamAuth) invalidate(peer string) { delete(a.tokens, peer) }
|
||||
|
||||
func (a *upstreamAuth) authenticate(ctx context.Context, peer string) (string, error) {
|
||||
body, status, err := postJSON(ctx, a.client, peer+"/v1/auth/challenge", nil)
|
||||
if err == nil && status == http.StatusTooManyRequests && bytes.Contains(body, []byte("proof of work")) {
|
||||
// Admission control is on: fetch a PoW challenge, solve for the
|
||||
// zero target, retry with the proof attached.
|
||||
powBody, pstatus, perr := postJSON(ctx, a.client, peer+"/v1/pow/challenge", map[string]string{"purpose": "auth"})
|
||||
if perr != nil || pstatus != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: pow challenge", errAuthFailed)
|
||||
}
|
||||
var pc struct {
|
||||
Key string `json:"key"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
}
|
||||
json.Unmarshal(powBody, &pc)
|
||||
key, err := pow.ParseKey(pc.Key)
|
||||
if err != nil {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
counter, ok := pow.Solve(key, [pow.TargetSize]byte{}, pc.Difficulty)
|
||||
if !ok {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
body, status, err = postJSON(ctx, a.client, peer+"/v1/auth/challenge",
|
||||
map[string]any{"pow": map[string]any{"key": pc.Key, "counter": counter}})
|
||||
}
|
||||
if err != nil || status != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: challenge status %d", errAuthFailed, status)
|
||||
}
|
||||
var ch struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
json.Unmarshal(body, &ch)
|
||||
chBytes, err := hex.DecodeString(ch.Challenge)
|
||||
if err != nil || len(chBytes) != 32 {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
|
||||
audience, err := a.audience(ctx, peer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a2 := &protocol.AuthAssertion{
|
||||
PubKey: a.key.Public(),
|
||||
Challenge: chBytes,
|
||||
Scope: "read",
|
||||
Audience: audience,
|
||||
CreatedAt: uint64(time.Now().Unix()),
|
||||
}
|
||||
tb, err := protocol.EncodeAuthAssertion(a2)
|
||||
if err != nil {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
sig := a.key.Sign(tb)
|
||||
envBody, _, err := postJSON(ctx, a.client, peer+"/v1/auth/assert",
|
||||
map[string]any{"tce": base64.StdEncoding.EncodeToString(tb),
|
||||
"signature": base64.StdEncoding.EncodeToString(sig)})
|
||||
if err != nil || !bytes.Contains(envBody, []byte("session_token")) {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
var out struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
json.Unmarshal(envBody, &out)
|
||||
if out.SessionToken == "" {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
return out.SessionToken, nil
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, client *http.Client, url string, body any) ([]byte, int, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rd)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
return raw, resp.StatusCode, err
|
||||
}
|
||||
|
||||
var _ = base64.StdEncoding
|
||||
|
||||
// audience fetches and caches the relay's audience binding.
|
||||
func (a *upstreamAuth) audience(ctx context.Context, peer string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/config", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var cfg struct {
|
||||
Audience string `json:"audience"`
|
||||
}
|
||||
json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&cfg)
|
||||
if cfg.Audience == "" {
|
||||
return "", errAuthFailed
|
||||
}
|
||||
return cfg.Audience, nil
|
||||
}
|
||||
|
|
@ -1,463 +0,0 @@
|
|||
package lightnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/bft"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
)
|
||||
|
||||
// errNoDecision is returned until the first quorum-approved head exists.
|
||||
var errNoDecision = errors.New("lightnode: no agreed checkpoint yet")
|
||||
|
||||
func (n *Node) pickPeer() string {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for _, p := range n.cfg.Peers {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// objectFromCache returns a cached envelope body, if present.
|
||||
func (n *Node) objectFromCache(id string) ([]byte, bool) {
|
||||
if n.cfg.CacheDir == "" {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(n.cfg.CacheDir, "objects", id+".json"))
|
||||
return raw, err == nil
|
||||
}
|
||||
|
||||
func (n *Node) cacheObject(id string, body []byte) {
|
||||
if n.cfg.CacheDir == "" {
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(n.cfg.CacheDir, "objects")
|
||||
if os.MkdirAll(dir, 0o700) == nil {
|
||||
_ = os.WriteFile(filepath.Join(dir, id+".json"), body, 0o600)
|
||||
}
|
||||
n.evictLocked()
|
||||
}
|
||||
|
||||
// evictLocked drops oldest cached objects while over budget.
|
||||
func (n *Node) evictLocked() {
|
||||
dir := filepath.Join(n.cfg.CacheDir, "objects")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type item struct {
|
||||
name string
|
||||
size int64
|
||||
mod int64
|
||||
}
|
||||
var items []item
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{e.Name(), fi.Size(), fi.ModTime().UnixNano()})
|
||||
total += fi.Size()
|
||||
}
|
||||
if total <= n.cfg.CacheMaxBytes {
|
||||
return
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].mod < items[j].mod })
|
||||
for _, it := range items {
|
||||
if total <= n.cfg.CacheMaxBytes {
|
||||
break
|
||||
}
|
||||
if os.Remove(filepath.Join(dir, it.name)) == nil {
|
||||
total -= it.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decisionRoot returns the root every served proof must match.
|
||||
func (n *Node) decisionRoot() ([32]byte, error) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.decision == nil {
|
||||
return [32]byte{}, errNoDecision
|
||||
}
|
||||
return n.decision.Root, nil
|
||||
}
|
||||
|
||||
// decisionRootOK is decisionRoot with a boolean for callers that treat "no
|
||||
// decision yet" as a plain no-op.
|
||||
func (n *Node) decisionRootOK() ([32]byte, bool) {
|
||||
r, err := n.decisionRoot()
|
||||
return r, err == nil
|
||||
}
|
||||
|
||||
// inclusionProofFromPeer fetches an inclusion proof from any peer. The
|
||||
// caller verifies it against the root it trusts.
|
||||
func (n *Node) inclusionProofFromPeer(idHex string) ([]byte, error) {
|
||||
pr, err := n.verifiedProof(context.Background(), idHex, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(pr.Proof)
|
||||
}
|
||||
|
||||
type proofResponse struct {
|
||||
Proof string `json:"proof"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
// verifiedProof fetches an inclusion/absence proof from any peer and accepts
|
||||
// it only if it verifies against the quorum-agreed root.
|
||||
func (n *Node) verifiedProof(ctx context.Context, idHex string, wantPresent bool) (*proofResponse, error) {
|
||||
root, err := n.decisionRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind := "absent"
|
||||
if wantPresent {
|
||||
kind = "object"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
n.pickPeer()+"/v1/proof/"+kind+"/"+idHex, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := n.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("lightnode: proof status " + resp.Status)
|
||||
}
|
||||
var pr proofResponse
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&pr) != nil {
|
||||
return nil, errors.New("lightnode: bad proof json")
|
||||
}
|
||||
proof, err := base64.StdEncoding.DecodeString(pr.Proof)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id [32]byte
|
||||
idb, err := hex.DecodeString(idHex)
|
||||
if err != nil || len(idb) != 32 {
|
||||
return nil, errors.New("lightnode: bad id")
|
||||
}
|
||||
copy(id[:], idb)
|
||||
if wantPresent && !smt.VerifyInclusion(root, id, proof) {
|
||||
return nil, errors.New("lightnode: inclusion proof rejected against quorum root")
|
||||
}
|
||||
if !wantPresent && !smt.VerifyAbsence(root, id, proof) {
|
||||
return nil, errors.New("lightnode: absence proof rejected against quorum root")
|
||||
}
|
||||
return &pr, nil
|
||||
}
|
||||
|
||||
// Handler returns the relay-compatible read API plus node status.
|
||||
func (n *Node) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /v1/objects/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if len(id) != 64 {
|
||||
writeErr(w, http.StatusBadRequest, "bad object id")
|
||||
return
|
||||
}
|
||||
if body, ok := n.objectFromCache(id); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Cache", "hit")
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
pr, err := n.verifiedProof(r.Context(), id, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
n.pickPeer()+"/v1/objects/"+id, nil)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := n.http.Do(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeErr(w, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
n.cacheObject(id, body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Proof-Root", pr.Root)
|
||||
w.Write(body)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/checkpoint/latest", func(w http.ResponseWriter, r *http.Request) {
|
||||
n.mu.Lock()
|
||||
d := n.decision
|
||||
n.mu.Unlock()
|
||||
if d == nil {
|
||||
writeErr(w, http.StatusNotFound, "no agreed checkpoint yet")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"bytes": base64.StdEncoding.EncodeToString(d.Bytes),
|
||||
"signature": base64.StdEncoding.EncodeToString(d.Signature),
|
||||
"id": d.ID,
|
||||
"public_key": d.PublicKey,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/proof/object/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveVerifiedProof(n, w, r, true)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/proof/absent/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveVerifiedProof(n, w, r, false)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
keys := make([]string, 0, len(n.pins))
|
||||
for k := range n.pins {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"mode": "lightnode",
|
||||
"peers": n.cfg.Peers,
|
||||
"pinned_keys": keys,
|
||||
"quorum": n.cfg.Quorum,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/bft/certificate/{height}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(n.cfg.BFTValidators) == 0 {
|
||||
writeErr(w, http.StatusNotImplemented, "no validator set pinned")
|
||||
return
|
||||
}
|
||||
h := r.PathValue("height")
|
||||
for _, peer := range n.cfg.Peers {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
|
||||
strings.TrimSuffix(peer, "/")+"/v1/bft/certificate/"+h, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp, err := n.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
var cert bft.Certificate
|
||||
if json.Unmarshal(raw, &cert) != nil {
|
||||
continue
|
||||
}
|
||||
set, err := bft.NewSet(n.cfg.BFTValidators, make([]string, len(n.cfg.BFTValidators)))
|
||||
if err != nil || set.VerifyCertificate(&cert) != nil {
|
||||
writeErr(w, http.StatusConflict, "certificate fails against pinned validators")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(raw)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusBadGateway, "no peer served a certificate")
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveDownstreamWS(n, w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
n.mu.Lock()
|
||||
d := n.decision
|
||||
n.mu.Unlock()
|
||||
if d == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "no agreed checkpoint yet")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func serveVerifiedProof(n *Node, w http.ResponseWriter, r *http.Request, wantPresent bool) {
|
||||
id := r.PathValue("id")
|
||||
if len(id) != 64 {
|
||||
writeErr(w, http.StatusBadRequest, "bad object id")
|
||||
return
|
||||
}
|
||||
pr, err := n.verifiedProof(r.Context(), id, wantPresent)
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, errNoDecision) {
|
||||
status = http.StatusServiceUnavailable
|
||||
} else if strings.Contains(err.Error(), "rejected") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeErr(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// Run refreshes on a ticker until ctx ends.
|
||||
func (n *Node) Run(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = n.Refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveDownstreamWS upgrades a local client and mirrors the relay's frame
|
||||
// protocol. No authentication: the light node is an operator-run local
|
||||
// mirror, and its own credentials never delegate.
|
||||
func serveDownstreamWS(n *Node, w http.ResponseWriter, r *http.Request) {
|
||||
wsn, err := n.WS()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ws unavailable")
|
||||
return
|
||||
}
|
||||
conn, err := websocketAccept(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusInternalError, "")
|
||||
|
||||
client := &lnClient{send: make(chan []byte, lnSendBuffer), done: make(chan struct{})}
|
||||
wsn.hub.mu.Lock()
|
||||
if len(wsn.hub.clients) >= lnMaxConns {
|
||||
wsn.hub.mu.Unlock()
|
||||
conn.Close(websocket.StatusPolicyViolation, "too many connections")
|
||||
return
|
||||
}
|
||||
wsn.hub.clients[client] = struct{}{}
|
||||
wsn.hub.mu.Unlock()
|
||||
defer wsn.hub.evict(client)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-client.done:
|
||||
conn.Close(websocket.StatusPolicyViolation, "evicted")
|
||||
return
|
||||
case payload := <-client.send:
|
||||
if err := conn.Write(r.Context(), websocket.MessageText, payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
typ, raw, err := conn.Read(r.Context())
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Op string `json:"op"`
|
||||
Channel string `json:"channel"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if json.Unmarshal(raw, &msg) != nil || (msg.Op != "subscribe" && msg.Op != "unsubscribe") {
|
||||
safeSend(conn, r, map[string]string{"event": "error", "message": "bad control frame"})
|
||||
continue
|
||||
}
|
||||
if !lnChannelKnown(msg.Channel) {
|
||||
safeSend(conn, r, map[string]string{"event": "error", "message": "unknown channel"})
|
||||
continue
|
||||
}
|
||||
|
||||
wsn.hub.mu.Lock()
|
||||
switch msg.Op {
|
||||
case "subscribe":
|
||||
if msg.Key == "" || len(client.subs) >= lnMaxSubsPerConn {
|
||||
wsn.hub.mu.Unlock()
|
||||
safeSend(conn, r, map[string]string{"event": "error", "message": "bad key or too many subscriptions"})
|
||||
continue
|
||||
}
|
||||
client.subs = append(client.subs, lnSub{msg.Channel, msg.Key})
|
||||
wsn.hub.demand[lnSub{msg.Channel, msg.Key}]++
|
||||
case "unsubscribe":
|
||||
kept := client.subs[:0]
|
||||
for _, x := range client.subs {
|
||||
if x.channel == msg.Channel && (msg.Key == "" || x.key == msg.Key) {
|
||||
k := lnSub{x.channel, x.key}
|
||||
wsn.hub.demand[k]--
|
||||
if wsn.hub.demand[k] <= 0 {
|
||||
delete(wsn.hub.demand, k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, x)
|
||||
}
|
||||
client.subs = kept
|
||||
}
|
||||
wsn.hub.mu.Unlock()
|
||||
wsn.hub.signal()
|
||||
safeSend(conn, r, map[string]string{"event": "subscribed", "channel": msg.Channel, "key": msg.Key})
|
||||
}
|
||||
<-done // let the writer drain before the deferred close kills it
|
||||
}
|
||||
|
||||
func safeSend(conn *websocket.Conn, r *http.Request, v any) {
|
||||
raw, _ := json.Marshal(v)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, raw)
|
||||
}
|
||||
|
||||
func lnChannelKnown(ch string) bool {
|
||||
switch ch {
|
||||
case "claims", "requests", "responses", "revocations":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
package lightnode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/server"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
||||
)
|
||||
|
||||
// relayFixture is one full relay plus a helper to store objects into it.
|
||||
type relayFixture struct {
|
||||
ts *httptest.Server
|
||||
srv *server.Server
|
||||
}
|
||||
|
||||
func newRelay(t *testing.T, dir string) *relayFixture {
|
||||
t.Helper()
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
|
||||
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
srv.StartCheckpoints(ctx)
|
||||
t.Cleanup(cancel)
|
||||
return &relayFixture{ts: httptest.NewServer(srv.Handler()), srv: srv}
|
||||
}
|
||||
|
||||
func (f *relayFixture) putEnvelope(t *testing.T, b, sig []byte) string {
|
||||
t.Helper()
|
||||
resp, err := http.Post(f.ts.URL+"/v1/objects", "application/json",
|
||||
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(b), "signature": b64(sig)})))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("put status %d: %s", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
return out.ObjectID
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestLightnodeQuorumAndServing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newRelay(t, filepath.Join(dir, "a"))
|
||||
c := newRelay(t, filepath.Join(dir, "c"))
|
||||
|
||||
// Both relays must hold the SAME object set: the root is a function of
|
||||
// the set, so agreement is possible only over identical storage.
|
||||
b, sig := makeTestClaim(t)
|
||||
objA := a.putEnvelope(t, b, sig)
|
||||
objC := c.putEnvelope(t, b, sig)
|
||||
if objA != objC {
|
||||
t.Fatal("same content produced different ids")
|
||||
}
|
||||
|
||||
cfgA := getConfigPubkeys(t, a.ts.URL)
|
||||
cfgC := getConfigPubkeys(t, c.ts.URL)
|
||||
|
||||
cacheDir := filepath.Join(dir, "cache")
|
||||
node := lightnode.New(lightnode.Config{
|
||||
Peers: []string{a.ts.URL, c.ts.URL},
|
||||
PinKeys: []string{cfgA, cfgC},
|
||||
Quorum: 2,
|
||||
CacheDir: cacheDir,
|
||||
CacheMaxBytes: 1 << 20,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go node.Run(ctx, 50*time.Millisecond)
|
||||
|
||||
// Wait for the first agreed decision.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for node.Decision() == nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no quorum decision reached")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Serve an object through the light node and verify caching works even
|
||||
// after both peers disappear.
|
||||
lts := httptest.NewServer(node.Handler())
|
||||
defer lts.Close()
|
||||
|
||||
fetch := func(url string) int {
|
||||
resp, err := http.Get(url + "/v1/objects/" + objA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
if code := fetch(lts.URL); code != http.StatusOK {
|
||||
t.Fatalf("first fetch status %d", code)
|
||||
}
|
||||
// Absence of an unknown object must also verify against the root
|
||||
// (needs a live peer for proof transport).
|
||||
resp, err := http.Get(lts.URL + "/v1/proof/absent/" + hexOnes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verified absence status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
a.ts.Close()
|
||||
c.ts.Close()
|
||||
|
||||
// The fetched object survives peer loss from the local cache.
|
||||
if code := fetch(lts.URL); code != http.StatusOK {
|
||||
t.Fatalf("cached fetch after peers down: status %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cacheDir, "objects", objA+".json")); err != nil {
|
||||
t.Fatal("object not cached to disk:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightnodeRejectsSplitView(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Two relays with disjoint object sets commit to different roots.
|
||||
x := newRelay(t, filepath.Join(dir, "x"))
|
||||
y := newRelay(t, filepath.Join(dir, "y"))
|
||||
bx, sx := makeTestClaim(t)
|
||||
x.putEnvelope(t, bx, sx)
|
||||
by, sy := makeTestClaim(t)
|
||||
y.putEnvelope(t, by, sy)
|
||||
|
||||
node := lightnode.New(lightnode.Config{
|
||||
Peers: []string{x.ts.URL, y.ts.URL},
|
||||
PinKeys: []string{getConfigPubkeys(t, x.ts.URL), getConfigPubkeys(t, y.ts.URL)},
|
||||
Quorum: 2,
|
||||
})
|
||||
if err := node.Refresh(context.Background()); err == nil {
|
||||
t.Skip("sets happened to agree; rerun")
|
||||
} else if node.Decision() != nil {
|
||||
t.Fatal("decision made despite split view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightnodeUnpinnedKeyRejectedWhenPinningEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newRelay(t, filepath.Join(dir, "a"))
|
||||
other := newRelay(t, filepath.Join(dir, "other"))
|
||||
b1, s1 := makeTestClaim(t)
|
||||
a.putEnvelope(t, b1, s1)
|
||||
b2, s2 := makeTestClaim(t)
|
||||
other.putEnvelope(t, b2, s2)
|
||||
|
||||
node := lightnode.New(lightnode.Config{
|
||||
Peers: []string{a.ts.URL, other.ts.URL},
|
||||
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)}, // only A is trusted
|
||||
})
|
||||
err := node.Refresh(context.Background())
|
||||
if err == nil && node.Decision() == nil {
|
||||
t.Fatal("expected error or decision")
|
||||
}
|
||||
if err == nil {
|
||||
// Refresh succeeded only because quorum defaults to len(peers)=2...
|
||||
// With pinning on, the unpinned peer must not have contributed.
|
||||
t.Log("refresh ok; verifying contribution count via pins")
|
||||
if len(node.Decision().PublicKey) == 0 {
|
||||
t.Fatal("empty decision")
|
||||
}
|
||||
} else if err != lightnode.ErrUnpinnedKey && err.Error() != lightnode.ErrNoQuorum.Error() {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getConfigPubkeys(t *testing.T, url string) string {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url + "/v1/config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var cfg struct {
|
||||
RelayPubkey string `json:"relay_pubkey"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||
if cfg.RelayPubkey == "" {
|
||||
t.Fatal("peer did not expose relay_pubkey")
|
||||
}
|
||||
return cfg.RelayPubkey
|
||||
}
|
||||
|
||||
func hexOnes() string {
|
||||
b := make([]byte, 32)
|
||||
for i := range b {
|
||||
b[i] = 0xEE
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func makeTestClaim(t *testing.T) ([]byte, []byte) {
|
||||
t.Helper()
|
||||
issuer, _ := signer.Generate()
|
||||
subject, _ := signer.Generate()
|
||||
c := &protocol.Claim{
|
||||
Issuer: issuer.Public(),
|
||||
Subject: subject.Public(),
|
||||
Claims: map[string]tce.Value{"light.test": tce.Bool(true)},
|
||||
CreatedAt: uint64(time.Now().Unix()),
|
||||
Serial: 1,
|
||||
Nonce: bytes.Repeat([]byte{0x07}, tce.NonceSize),
|
||||
}
|
||||
b, err := protocol.EncodeClaim(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, issuer.Sign(b)
|
||||
}
|
||||
|
||||
func TestWSMirroredThroughLightnode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newRelay(t, filepath.Join(dir, "a"))
|
||||
|
||||
issuer, _ := signer.Generate()
|
||||
subject, _ := signer.Generate()
|
||||
subjAddr := subject.Identity().String()
|
||||
|
||||
mkClaim := func(nonce byte, serial uint64) ([]byte, []byte) {
|
||||
c := &protocol.Claim{
|
||||
Issuer: issuer.Public(),
|
||||
Subject: subject.Public(),
|
||||
Claims: map[string]tce.Value{"ws.mirror": tce.Bool(true)},
|
||||
CreatedAt: uint64(time.Now().Unix()),
|
||||
Serial: serial,
|
||||
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
|
||||
}
|
||||
cb, err := protocol.EncodeClaim(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cb, issuer.Sign(cb)
|
||||
}
|
||||
|
||||
b, sig := mkClaim(0x60, 6)
|
||||
a.putEnvelope(t, b, sig)
|
||||
|
||||
node := lightnode.New(lightnode.Config{
|
||||
Peers: []string{a.ts.URL},
|
||||
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)},
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go node.Run(ctx, 30*time.Millisecond)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for node.Decision() == nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no quorum decision")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
wsn, err := node.WS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go wsn.RunWS(ctx)
|
||||
|
||||
lts := httptest.NewServer(node.Handler())
|
||||
defer lts.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(lts.URL, "http") + "/v1/ws"
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
readEvent := func() map[string]any {
|
||||
t.Helper()
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
var ev map[string]any
|
||||
json.Unmarshal(raw, &ev)
|
||||
return ev
|
||||
}
|
||||
subRaw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": "claims", "key": subjAddr})
|
||||
if err := conn.Write(ctx, websocket.MessageText, subRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev := readEvent(); ev["event"] != "subscribed" {
|
||||
t.Fatalf("no ack: %v", ev)
|
||||
}
|
||||
|
||||
// The relay only broadcasts at store time, so wait until the mirror's
|
||||
// upstream subscription is live before publishing the matched claim.
|
||||
deadline2 := time.Now().Add(3 * time.Second)
|
||||
for !wsn.UpstreamReady() {
|
||||
if time.Now().After(deadline2) {
|
||||
t.Fatal("upstream stream never came up")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond) // let the relay process the subscribe frame
|
||||
|
||||
// A second claim about the subscribed subject flows through the mirror.
|
||||
cb, csig := mkClaim(0x61, 7)
|
||||
resp, err := http.Post(a.ts.URL+"/v1/objects", "application/json",
|
||||
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(cb), "signature": b64(csig)})))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
ev := readEvent()
|
||||
if ev["event"] != "object" || ev["key"] != subjAddr {
|
||||
t.Fatalf("unexpected event: %v", ev)
|
||||
}
|
||||
envMap, _ := ev["envelope"].(map[string]any)
|
||||
if envMap == nil || envMap["tce"] == nil || envMap["signature"] == nil {
|
||||
t.Fatal("event missing raw envelope")
|
||||
}
|
||||
if v, ok := ev["verified"].(bool); !ok || v {
|
||||
t.Fatalf("fresh stream must be marked unverified: %v", ev["verified"])
|
||||
}
|
||||
|
||||
// The streamed envelope must verify locally exactly like a fetched one.
|
||||
tb, _ := base64.StdEncoding.DecodeString(envMap["tce"].(string))
|
||||
tsig, _ := base64.StdEncoding.DecodeString(envMap["signature"].(string))
|
||||
typName, verr := (&transport.Envelope{TCE: tb, Signature: tsig}).Verify()
|
||||
if verr != nil || typName != "claim" {
|
||||
t.Fatalf("streamed envelope fails local verification: %v %q", verr, typName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
// Package lightnode implements a verifying mirror of the relay API that
|
||||
// stores no history: it tracks signed checkpoints from a small set of peers,
|
||||
// demands a quorum agree on the committed root, then serves objects fetched
|
||||
// on demand — every object checked against an inclusion proof before it is
|
||||
// cached or returned. Storage cost is the working set plus ~100 bytes of
|
||||
// heads, which is what makes a phone-sized node possible.
|
||||
//
|
||||
// The light node never evaluates trust: it serves exactly the envelopes a
|
||||
// full relay would, after proving they belong to the agreed set. Consumers
|
||||
// keep using verify.Graph locally (INV-5 preserved end to end).
|
||||
package lightnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
)
|
||||
|
||||
// Config configures a light node.
|
||||
type Config struct {
|
||||
// Peers are relay base URLs, e.g. https://trust.n1ko.dev.
|
||||
Peers []string
|
||||
|
||||
// PinKeys are hex Ed25519 relay public keys to trust. Empty enables
|
||||
// trust-on-first-use: the first key seen for a peer is recorded.
|
||||
PinKeys []string
|
||||
|
||||
// Quorum is how many distinct relay keys must agree on one root for the
|
||||
// node to accept it. Zero means all peers.
|
||||
Quorum int
|
||||
|
||||
// CacheDir stores fetched envelopes; empty disables persistence.
|
||||
CacheDir string
|
||||
|
||||
// CacheMaxBytes caps the cache directory; oldest files evicted first.
|
||||
CacheMaxBytes int64
|
||||
|
||||
// BFTValidators are hex public keys of the validator set. When set, the
|
||||
// node exposes GET /v1/bft/certificate/{height} and verifies every
|
||||
// certificate against exactly these keys before serving it.
|
||||
BFTValidators []string
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrNoQuorum means fewer agreeing keys than configured.
|
||||
ErrNoQuorum = errors.New("lightnode: no quorum")
|
||||
|
||||
// ErrUnpinnedKey means TOFU is off and an unknown relay key answered.
|
||||
ErrUnpinnedKey = errors.New("lightnode: unpinned relay key")
|
||||
)
|
||||
|
||||
// Head is one verified checkpoint observation from one relay key.
|
||||
type Head struct {
|
||||
PublicKey string
|
||||
ID string
|
||||
Epoch uint64
|
||||
Size uint64
|
||||
Root [32]byte
|
||||
Bytes []byte
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
// Node is the light node core. Safe for concurrent use.
|
||||
type Node struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
pins map[string]bool
|
||||
heads map[string]*Head // relay pubkey -> newest verified head
|
||||
decision *Head // quorum-approved head; nil until first success
|
||||
lastErr error
|
||||
|
||||
wsOnce sync.Once
|
||||
ws *wsNode
|
||||
wsErr error
|
||||
}
|
||||
|
||||
// WS returns the streaming subsystem, building it on first use.
|
||||
func (n *Node) WS() (*wsNode, error) {
|
||||
n.wsOnce.Do(func() {
|
||||
n.ws, n.wsErr = newWSNode(n)
|
||||
})
|
||||
return n.ws, n.wsErr
|
||||
}
|
||||
|
||||
// New constructs a node.
|
||||
func New(cfg Config) *Node {
|
||||
if cfg.Quorum <= 0 {
|
||||
cfg.Quorum = len(cfg.Peers)
|
||||
}
|
||||
if cfg.CacheMaxBytes == 0 {
|
||||
cfg.CacheMaxBytes = 256 << 20 // 256 MiB default working set
|
||||
}
|
||||
n := &Node{
|
||||
cfg: cfg,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
pins: make(map[string]bool),
|
||||
heads: make(map[string]*Head),
|
||||
}
|
||||
for _, k := range cfg.PinKeys {
|
||||
n.pins[k] = true
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type wireHead struct {
|
||||
Bytes string `json:"bytes"`
|
||||
Signature string `json:"signature"`
|
||||
ID string `json:"id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Checkpoint struct {
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Size uint64 `json:"size"`
|
||||
Root string `json:"root"`
|
||||
} `json:"checkpoint"`
|
||||
}
|
||||
|
||||
func (n *Node) fetchHead(ctx context.Context, peer string) (*Head, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/checkpoint/latest", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := n.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("lightnode: peer status " + resp.Status)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var w wireHead
|
||||
if json.Unmarshal(raw, &w) != nil {
|
||||
return nil, errors.New("lightnode: bad head json")
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(w.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("lightnode: bad head bytes")
|
||||
}
|
||||
cp, err := checkpoint.Decode(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(w.Signature)
|
||||
if err != nil {
|
||||
return nil, errors.New("lightnode: bad signature encoding")
|
||||
}
|
||||
pub, err := hex.DecodeString(w.PublicKey)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
return nil, ErrUnpinnedKey
|
||||
}
|
||||
if err := checkpoint.Verify(ed25519.PublicKey(pub), b, sig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var root [32]byte
|
||||
rb, _ := hex.DecodeString(w.Checkpoint.Root)
|
||||
copy(root[:], rb)
|
||||
idSum := checkpoint.ID(b)
|
||||
if len(w.ID) == 64 && hex.EncodeToString(idSum[:]) != w.ID {
|
||||
return nil, errors.New("lightnode: head id mismatch")
|
||||
}
|
||||
return &Head{
|
||||
PublicKey: w.PublicKey,
|
||||
ID: w.ID,
|
||||
Epoch: cp.Epoch,
|
||||
Size: cp.Size,
|
||||
Root: root,
|
||||
Bytes: b,
|
||||
Signature: sig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refresh polls every peer concurrently, records verified observations and
|
||||
// recomputes the decision.
|
||||
func (n *Node) Refresh(ctx context.Context) error {
|
||||
type result struct {
|
||||
head *Head
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, len(n.cfg.Peers))
|
||||
for _, p := range n.cfg.Peers {
|
||||
go func(peer string) {
|
||||
h, err := n.fetchHead(ctx, peer)
|
||||
ch <- result{h, err}
|
||||
}(p)
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
var firstErr error
|
||||
for range n.cfg.Peers {
|
||||
res := <-ch
|
||||
if res.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = res.err
|
||||
}
|
||||
continue
|
||||
}
|
||||
h := res.head
|
||||
if !n.pins[h.PublicKey] {
|
||||
if len(n.cfg.PinKeys) > 0 {
|
||||
if firstErr == nil {
|
||||
firstErr = ErrUnpinnedKey
|
||||
}
|
||||
continue
|
||||
}
|
||||
n.pins[h.PublicKey] = true // TOFU
|
||||
}
|
||||
if old, ok := n.heads[h.PublicKey]; !ok || h.Epoch >= old.Epoch {
|
||||
n.heads[h.PublicKey] = h
|
||||
}
|
||||
}
|
||||
err := n.redecideLocked()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
n.lastErr = firstErr
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// redecideLocked requires the configured number of distinct relay keys to
|
||||
// agree on one root and picks the highest-epoch observation among them.
|
||||
func (n *Node) redecideLocked() error {
|
||||
byRoot := make(map[[32]byte][]*Head)
|
||||
for _, h := range n.heads {
|
||||
byRoot[h.Root] = append(byRoot[h.Root], h)
|
||||
}
|
||||
var best []*Head
|
||||
for _, group := range byRoot {
|
||||
if len(group) > len(best) {
|
||||
best = group
|
||||
}
|
||||
}
|
||||
if len(best) < n.cfg.Quorum {
|
||||
n.decision = nil
|
||||
return ErrNoQuorum
|
||||
}
|
||||
sort.Slice(best, func(i, j int) bool { return best[i].Epoch > best[j].Epoch })
|
||||
n.decision = best[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decision returns the currently accepted head, if any.
|
||||
func (n *Node) Decision() *Head {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return n.decision
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
package lightnode
|
||||
|
||||
// WebSocket mirroring. The node exposes the relay's streaming protocol to
|
||||
// its own clients and feeds it from upstream relays:
|
||||
//
|
||||
// client ──ws──▶ light node ──ws──▶ pinned relays
|
||||
//
|
||||
// Every envelope received upstream is signature-checked before forwarding
|
||||
// and deduplicated across peers by content ID. A freshly streamed object is
|
||||
// usually younger than the newest quorum-agreed checkpoint, so it cannot yet
|
||||
// carry an inclusion proof; such objects are forwarded marked
|
||||
// "verified": false and queued. The queue is re-proven against each new
|
||||
// agreed root purely as bookkeeping (and dropped once covered or exhausted):
|
||||
// clients that need proof-backed bytes should re-fetch /v1/objects/{id},
|
||||
// which only ever serves proof-verified content. Streaming is news,
|
||||
// fetching is evidence.
|
||||
//
|
||||
// Subscriptions demanded downstream are reconciled onto upstream
|
||||
// connections when they are (re)established; a subscription added while a
|
||||
// connection is already up takes effect at its next reconnect. Documented
|
||||
// v1 simplification.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
lnSendBuffer = 64
|
||||
lnMaxConns = 64
|
||||
lnMaxSubsPerConn = 16
|
||||
lnPendingCap = 512
|
||||
lnSeenCap = 10000
|
||||
)
|
||||
|
||||
type lnSub struct {
|
||||
channel, key string
|
||||
}
|
||||
|
||||
type lnClient struct {
|
||||
subs []lnSub
|
||||
send chan []byte
|
||||
done chan struct{}
|
||||
evicted bool
|
||||
}
|
||||
|
||||
type lnHub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*lnClient]struct{}
|
||||
demand map[lnSub]int // refcounted subscriptions demanded downstream
|
||||
kick chan struct{} // wakes the manager so it can dial missing peers
|
||||
}
|
||||
|
||||
func newLnHub() *lnHub {
|
||||
return &lnHub{
|
||||
clients: make(map[*lnClient]struct{}),
|
||||
demand: make(map[lnSub]int),
|
||||
kick: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// evict removes a client and drops its demand contributions.
|
||||
func (h *lnHub) evict(c *lnClient) {
|
||||
h.mu.Lock()
|
||||
if !c.evicted {
|
||||
c.evicted = true
|
||||
close(c.done)
|
||||
}
|
||||
delete(h.clients, c)
|
||||
for _, s := range c.subs {
|
||||
k := lnSub{s.channel, s.key}
|
||||
h.demand[k]--
|
||||
if h.demand[k] <= 0 {
|
||||
delete(h.demand, k)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.signal()
|
||||
}
|
||||
|
||||
func (h *lnHub) signal() {
|
||||
select {
|
||||
case h.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type pendingEvent struct {
|
||||
channel, key, objID string
|
||||
attempts int
|
||||
}
|
||||
|
||||
type wsNode struct {
|
||||
node *Node
|
||||
auth *upstreamAuth
|
||||
|
||||
hub *lnHub
|
||||
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
pending []pendingEvent
|
||||
upstreamLive map[string]bool
|
||||
upstreamStarting map[string]bool
|
||||
}
|
||||
|
||||
func newWSNode(n *Node) (*wsNode, error) {
|
||||
auth, err := newUpstreamAuth(n.cfg.CacheDir, n.http)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wsNode{
|
||||
node: n,
|
||||
auth: auth,
|
||||
hub: newLnHub(),
|
||||
seen: make(map[string]struct{}),
|
||||
upstreamLive: make(map[string]bool),
|
||||
upstreamStarting: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RunWS maintains one upstream stream per peer while subscriptions are
|
||||
// demanded downstream. It blocks until ctx ends.
|
||||
func (w *wsNode) RunWS(ctx context.Context) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-w.hub.kick:
|
||||
case <-ticker.C:
|
||||
}
|
||||
w.flushPending()
|
||||
w.connectAll(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// UpstreamReady reports whether at least one upstream stream is live. It is
|
||||
// a convenience for operators and tests: events published before this point
|
||||
// were never broadcast anywhere, so waiting on it avoids missing early ones.
|
||||
func (w *wsNode) UpstreamReady() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.upstreamLive) > 0
|
||||
}
|
||||
|
||||
func (w *wsNode) hasDemand() bool {
|
||||
w.hub.mu.Lock()
|
||||
defer w.hub.mu.Unlock()
|
||||
return len(w.hub.demand) > 0
|
||||
}
|
||||
|
||||
func (w *wsNode) connectAll(ctx context.Context) {
|
||||
if !w.hasDemand() {
|
||||
return
|
||||
}
|
||||
for _, peer := range w.node.cfg.Peers {
|
||||
w.mu.Lock()
|
||||
live := w.upstreamLive[peer] || w.upstreamStarting[peer]
|
||||
if !live {
|
||||
w.upstreamStarting[peer] = true
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if live {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(peer string) {
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
delete(w.upstreamStarting, peer)
|
||||
delete(w.upstreamLive, peer)
|
||||
w.mu.Unlock()
|
||||
}()
|
||||
w.serveUpstream(ctx, peer)
|
||||
}(peer)
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpstream runs one connection lifecycle: authenticate over HTTP,
|
||||
// upgrade with the token, subscribe to everything currently demanded, pump
|
||||
// events until the connection or ctx dies. Reconnection happens on the next
|
||||
// manager tick because demand persists.
|
||||
func (w *wsNode) serveUpstream(ctx context.Context, peer string) {
|
||||
token, err := w.auth.session(ctx, peer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
wsURL := strings.Replace(peer, "http", "ws", 1) + "/v1/ws?token=" + token
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
w.auth.invalidate(peer)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
w.hub.mu.Lock()
|
||||
for k := range w.hub.demand {
|
||||
raw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": k.channel, "key": k.key})
|
||||
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
||||
w.hub.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
w.hub.mu.Unlock()
|
||||
|
||||
w.mu.Lock()
|
||||
w.upstreamLive[peer] = true
|
||||
w.mu.Unlock()
|
||||
|
||||
for {
|
||||
typ, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
w.handleUpstreamEvent(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpstreamEvent(w *wsNode, raw []byte) { w.handleUpstreamEvent(raw) }
|
||||
|
||||
func (w *wsNode) handleUpstreamEvent(raw []byte) {
|
||||
var ev struct {
|
||||
Event string `json:"event"`
|
||||
Channel string `json:"channel"`
|
||||
Key string `json:"key"`
|
||||
ObjectID string `json:"object_id"`
|
||||
Envelope *struct {
|
||||
TCE []byte `json:"tce"`
|
||||
Signature []byte `json:"signature"`
|
||||
} `json:"envelope"`
|
||||
}
|
||||
if json.Unmarshal(raw, &ev) != nil || ev.Event != "object" || ev.Envelope == nil {
|
||||
return
|
||||
}
|
||||
objID := ev.ObjectID
|
||||
if len(objID) != 64 || ev.Channel == "" || ev.Key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
if _, dup := w.seen[objID]; dup {
|
||||
w.mu.Unlock()
|
||||
return // another peer already delivered this exact object
|
||||
}
|
||||
w.seen[objID] = struct{}{}
|
||||
if len(w.seen) > lnSeenCap {
|
||||
w.seen = make(map[string]struct{}) // coarse reset; dedupe is best-effort
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
env := &transport.Envelope{TCE: ev.Envelope.TCE, Signature: ev.Envelope.Signature}
|
||||
typName, err := env.Verify()
|
||||
if err != nil || !validLNChannel(typName) {
|
||||
return // never forward an envelope that does not verify locally
|
||||
}
|
||||
w.dispatch(ev.Channel, ev.Key, objID, ev.Envelope.TCE, ev.Envelope.Signature)
|
||||
}
|
||||
|
||||
func validLNChannel(typeName string) bool {
|
||||
switch typeName {
|
||||
case "claim", "request", "response", "revocation":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dispatch pushes to matching downstream clients and queues the object id
|
||||
// for proof bookkeeping against the next quorum-agreed root.
|
||||
func (w *wsNode) dispatch(channel, key, objID string, tceBytes, sig []byte) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "object",
|
||||
"channel": channel,
|
||||
"key": key,
|
||||
"object_id": objID,
|
||||
"envelope": map[string]any{"tce": tceBytes, "signature": sig},
|
||||
"verified": false,
|
||||
})
|
||||
|
||||
w.hub.mu.Lock()
|
||||
for c := range w.hub.clients {
|
||||
match := false
|
||||
for _, s := range c.subs {
|
||||
if s.channel == channel && s.key == key {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match || c.evicted {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case c.send <- payload:
|
||||
default:
|
||||
// Slow client under lock: close-once signalling, no send/close race.
|
||||
c.evicted = true
|
||||
close(c.done)
|
||||
delete(w.hub.clients, c)
|
||||
}
|
||||
}
|
||||
w.hub.mu.Unlock()
|
||||
|
||||
w.mu.Lock()
|
||||
if len(w.pending) < lnPendingCap {
|
||||
w.pending = append(w.pending, pendingEvent{channel: channel, key: key, objID: objID})
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// flushPending re-proves queued object ids against the newest agreed root,
|
||||
// keeping only ids that are still unproven and under the attempt budget.
|
||||
func (w *wsNode) flushPending() {
|
||||
root, ok := w.node.decisionRootOK()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
kept := w.pending[:0]
|
||||
for _, p := range w.pending {
|
||||
idb, err := hex.DecodeString(p.objID)
|
||||
var id [32]byte
|
||||
if err == nil && len(idb) == 32 {
|
||||
copy(id[:], idb)
|
||||
if proof, err := w.node.inclusionProofFromPeer(p.objID); err == nil &&
|
||||
smt.VerifyInclusion(root, id, proof) {
|
||||
continue // now covered by the quorum-agreed checkpoint
|
||||
}
|
||||
}
|
||||
p.attempts++
|
||||
if p.attempts < 3 {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
w.pending = kept
|
||||
}
|
||||
|
||||
// websocketAccept upgrades with permissive origins: the light node is a
|
||||
// local mirror whose authority comes from its pinned peers, not from the
|
||||
// browser that talks to it.
|
||||
func websocketAccept(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
|
||||
return websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
}
|
||||
|
||||
var _ = http.StatusText
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
// Package pow implements the relay's proof-of-work admission control.
|
||||
//
|
||||
// PoW is a transport-layer anti-abuse mechanism: it taxes anonymous flooding
|
||||
// without taxing honest low-volume clients, whose cost is one short solver
|
||||
// loop. It never enters TCE bytes, never affects any signature, and is
|
||||
// invisible to verifiers (PROTOCOL.md §9 keeps server-side data out of signed
|
||||
// statements).
|
||||
//
|
||||
// Scheme. The relay issues a single-use 32-byte challenge key. The client
|
||||
// finds a uint32 counter such that
|
||||
//
|
||||
// BLAKE3_keyed(key, Domain || target || counter_be)
|
||||
//
|
||||
// has at least `difficulty` leading zero bits. For object storage the target
|
||||
// is the object's content ID, binding the proof to that exact submission; for
|
||||
// authentication it is 32 zero bytes, because single-use consumption of a
|
||||
// fresh server-chosen key already carries the protection. Verification is one
|
||||
// hash call, so the asymmetry is total.
|
||||
package pow
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
blake3 "lukechampine.com/blake3"
|
||||
)
|
||||
|
||||
// Domain separates these hashes from every other use of BLAKE3 anywhere in
|
||||
// the tree. It is part of the hashed message, not the key.
|
||||
const Domain = "trust.n1ko.dev/pow/1"
|
||||
|
||||
// KeySize is the exact size of a challenge key.
|
||||
const KeySize = 32
|
||||
|
||||
// TargetSize is the exact size of the binding target.
|
||||
const TargetSize = 32
|
||||
|
||||
// MaxDifficulty is the highest configurable difficulty, in leading zero bits.
|
||||
//
|
||||
// Solving is defined over a uint32 counter, so a difficulty d has an expected
|
||||
// work of 2^d hashes and fails outright with probability exp(-2^(32-d)) once
|
||||
// the counter space is exhausted: negligible up to 30 bits, material beyond.
|
||||
// Configuration above MaxDifficulty is clamped.
|
||||
const MaxDifficulty = 30
|
||||
|
||||
// ErrBadKey is returned when hex text is not a valid challenge key.
|
||||
var ErrBadKey = errors.New("pow: malformed challenge key")
|
||||
|
||||
// Key is a server-issued challenge key. It exists to keep keys out of code
|
||||
// paths expecting arbitrary bytes.
|
||||
type Key [KeySize]byte
|
||||
|
||||
// NewKey draws a fresh challenge key from the CSPRNG.
|
||||
func NewKey() Key {
|
||||
var k Key
|
||||
if _, err := rand.Read(k[:]); err != nil {
|
||||
panic("pow: rand: " + err.Error())
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// ParseKey decodes lowercase hex challenge-key text.
|
||||
func ParseKey(s string) (Key, error) {
|
||||
var k Key
|
||||
if len(s) != KeySize*2 {
|
||||
return k, ErrBadKey
|
||||
}
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return k, ErrBadKey
|
||||
}
|
||||
copy(k[:], b)
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// String renders the key as lowercase hex.
|
||||
func (k Key) String() string { return hex.EncodeToString(k[:]) }
|
||||
|
||||
// Sum computes the proof hash for one candidate solution.
|
||||
func Sum(key Key, target [TargetSize]byte, counter uint32) [32]byte {
|
||||
h := blake3.New(32, key[:])
|
||||
buf := make([]byte, 0, len(Domain)+TargetSize+4)
|
||||
buf = append(buf, Domain...)
|
||||
buf = append(buf, target[:]...)
|
||||
buf = binary.BigEndian.AppendUint32(buf, counter)
|
||||
h.Write(buf)
|
||||
var out [32]byte
|
||||
h.Sum(out[:0])
|
||||
return out
|
||||
}
|
||||
|
||||
// LeadingZeroBits counts the zero bits at the top of sum.
|
||||
func LeadingZeroBits(sum [32]byte) int {
|
||||
n := 0
|
||||
for _, b := range sum {
|
||||
if b == 0 {
|
||||
n += 8
|
||||
continue
|
||||
}
|
||||
for b&0x80 == 0 {
|
||||
n++
|
||||
b <<= 1
|
||||
}
|
||||
break
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// MeetsTarget reports whether sum satisfies the difficulty in leading zero
|
||||
// bits. A difficulty of zero or less is always satisfied; a difficulty above
|
||||
// 256 never is.
|
||||
func MeetsTarget(sum [32]byte, difficulty int) bool {
|
||||
return difficulty <= 0 || LeadingZeroBits(sum) >= difficulty
|
||||
}
|
||||
|
||||
// Verify checks one claimed solution with a single hash call.
|
||||
func Verify(key Key, target [TargetSize]byte, difficulty int, counter uint32) bool {
|
||||
if difficulty < 0 || difficulty > MaxDifficulty {
|
||||
return false
|
||||
}
|
||||
return MeetsTarget(Sum(key, target, counter), difficulty)
|
||||
}
|
||||
|
||||
// Solve finds the smallest counter meeting the difficulty. The boolean is
|
||||
// false if the uint32 counter space is exhausted, which for valid
|
||||
// configurations (difficulty ≤ MaxDifficulty) happens only with the small
|
||||
// probability derived from the birthday-style tail.
|
||||
func Solve(key Key, target [TargetSize]byte, difficulty int) (uint32, bool) {
|
||||
if difficulty <= 0 {
|
||||
return 0, true
|
||||
}
|
||||
var c uint32
|
||||
for {
|
||||
if MeetsTarget(Sum(key, target, c), difficulty) {
|
||||
return c, true
|
||||
}
|
||||
if c == ^uint32(0) {
|
||||
return 0, false
|
||||
}
|
||||
c++
|
||||
}
|
||||
}
|
||||
|
||||
// Proof is one solved challenge as it travels beside an envelope.
|
||||
type Proof struct {
|
||||
Key Key `json:"-"`
|
||||
KeyHex string `json:"key"`
|
||||
Counter uint32 `json:"counter"`
|
||||
}
|
||||
|
||||
// ParseProof validates and decodes the wire form of a proof.
|
||||
func ParseProof(keyHex string, counter uint32) (Proof, error) {
|
||||
k, err := ParseKey(keyHex)
|
||||
if err != nil {
|
||||
return Proof{}, fmt.Errorf("pow: %w", err)
|
||||
}
|
||||
return Proof{Key: k, KeyHex: keyHex, Counter: counter}, nil
|
||||
}
|
||||
|
||||
// Equal reports whether two proofs carry the same fields, comparing key bytes
|
||||
// in constant time.
|
||||
func (p Proof) Equal(other Proof) bool {
|
||||
return subtle.ConstantTimeCompare(p.Key[:], other.Key[:]) == 1 && p.Counter == other.Counter
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
package pow_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
blake3 "lukechampine.com/blake3"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
)
|
||||
|
||||
func testKey(seed byte) pow.Key {
|
||||
var k pow.Key
|
||||
for i := range k {
|
||||
k[i] = seed + byte(i)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func testTarget(seed byte) [32]byte {
|
||||
var t [32]byte
|
||||
for i := range t {
|
||||
t[i] = seed * byte(i+1)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestSolveAndVerifyRoundTrip(t *testing.T) {
|
||||
key := testKey(0x10)
|
||||
target := testTarget(0x20)
|
||||
const difficulty = 12
|
||||
|
||||
counter, ok := pow.Solve(key, target, difficulty)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
if !pow.Verify(key, target, difficulty, counter) {
|
||||
t.Fatal("verify rejected own solution")
|
||||
}
|
||||
|
||||
sum := pow.Sum(key, target, counter)
|
||||
if n := pow.LeadingZeroBits(sum); n < difficulty {
|
||||
t.Fatalf("solution has %d leading zero bits, want >= %d", n, difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongInputs(t *testing.T) {
|
||||
key := testKey(0x01)
|
||||
target := testTarget(0x02)
|
||||
const difficulty = 10
|
||||
|
||||
counter, ok := pow.Solve(key, target, difficulty)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key pow.Key
|
||||
target [32]byte
|
||||
difficulty int
|
||||
counter uint32
|
||||
}{
|
||||
{"wrong key", testKey(0xFF), target, difficulty, counter},
|
||||
{"wrong target", key, testTarget(0xFE), difficulty, counter},
|
||||
{"wrong counter", key, target, difficulty, counter + 1},
|
||||
{"difficulty above max", key, target, pow.MaxDifficulty + 1, counter},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if pow.Verify(tc.key, tc.target, tc.difficulty, tc.counter) {
|
||||
t.Errorf("%s: verify accepted invalid proof", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveDeterministicSmallestCounter(t *testing.T) {
|
||||
key := testKey(0x33)
|
||||
target := testTarget(0x44)
|
||||
first, ok := pow.Solve(key, target, 8)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
second, _ := pow.Solve(key, target, 8)
|
||||
if first != second {
|
||||
t.Fatalf("solver nondeterministic: %d vs %d", first, second)
|
||||
}
|
||||
// The smallest counter is a real lower bound: nothing below verifies.
|
||||
for c := uint32(0); c < first; c++ {
|
||||
if pow.Verify(key, target, 8, c) {
|
||||
t.Fatalf("counter %d verifies but solver returned %d", c, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadingZeroBitsBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
b byte
|
||||
want int
|
||||
}{
|
||||
{0x80, 0},
|
||||
{0x40, 1},
|
||||
{0x01, 7},
|
||||
{0x00, 8},
|
||||
}
|
||||
var sum [32]byte
|
||||
for _, tc := range cases {
|
||||
for i := range sum {
|
||||
sum[i] = 0xFF
|
||||
}
|
||||
sum[0] = tc.b
|
||||
if got := pow.LeadingZeroBits(sum); got != tc.want {
|
||||
t.Errorf("byte %02x: got %d leading zero bits, want %d", tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sum {
|
||||
sum[i] = 0
|
||||
}
|
||||
if got := pow.LeadingZeroBits(sum); got != 256 {
|
||||
t.Errorf("all-zero sum: got %d, want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetsTargetDifficultySemantics(t *testing.T) {
|
||||
var zero [32]byte
|
||||
var full [32]byte
|
||||
for i := range full {
|
||||
full[i] = 0xFF
|
||||
}
|
||||
|
||||
if !pow.MeetsTarget(zero, 256) {
|
||||
t.Error("all-zero hash must meet any representable difficulty")
|
||||
}
|
||||
if pow.MeetsTarget(full, 1) {
|
||||
t.Error("all-ff hash must not meet difficulty 1")
|
||||
}
|
||||
if !pow.MeetsTarget(full, 0) || !pow.MeetsTarget(full, -5) {
|
||||
t.Error("difficulty <= 0 must always pass (disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumMatchesManualConstruction(t *testing.T) {
|
||||
// Recompute the hash input independently and compare against Sum.
|
||||
key := testKey(0x55)
|
||||
target := testTarget(0x66)
|
||||
const counter = 0x01020304
|
||||
|
||||
h := blake3.New(32, key[:])
|
||||
h.Write([]byte(pow.Domain))
|
||||
h.Write(target[:])
|
||||
var cb [4]byte
|
||||
binary.BigEndian.PutUint32(cb[:], counter)
|
||||
h.Write(cb[:])
|
||||
var want [32]byte
|
||||
copy(want[:], h.Sum(nil))
|
||||
|
||||
got := pow.Sum(key, target, counter)
|
||||
if !bytes.Equal(got[:], want[:]) {
|
||||
t.Fatalf("Sum mismatch:\n got %x\nwant %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyParseAndEqual(t *testing.T) {
|
||||
key := testKey(0x77)
|
||||
parsed, err := pow.ParseKey(key.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proof := pow.Proof{Key: parsed, KeyHex: parsed.String(), Counter: 1}
|
||||
same, _ := pow.ParseKey(key.String())
|
||||
if !proof.Equal(pow.Proof{Key: same, KeyHex: same.String(), Counter: 1}) {
|
||||
t.Error("equal proofs compared unequal")
|
||||
}
|
||||
if proof.Equal(pow.Proof{Key: parsed, KeyHex: parsed.String(), Counter: 2}) {
|
||||
t.Error("different counters compared equal")
|
||||
}
|
||||
|
||||
bad := []string{"", "00", "ZZ", key.String() + "00"}
|
||||
for _, s := range bad {
|
||||
if _, err := pow.ParseKey(s); err == nil {
|
||||
t.Errorf("ParseKey(%q) accepted malformed key", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeyUnique(t *testing.T) {
|
||||
seen := make(map[pow.Key]struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
k := pow.NewKey()
|
||||
if _, dup := seen[k]; dup {
|
||||
t.Fatal("CSPRNG produced duplicate key")
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Solve at MaxDifficulty must terminate within the uint32 space with
|
||||
// overwhelming probability; this bounds runtime while exercising deep loops.
|
||||
func TestSolveHighDifficultyTerminates(t *testing.T) {
|
||||
key := testKey(0x99)
|
||||
target := testTarget(0xAA)
|
||||
_, ok := pow.Solve(key, target, 20)
|
||||
if !ok {
|
||||
t.Skip("counter space exhausted for this key/target pair")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkVerify(b *testing.B) {
|
||||
key := testKey(0xAB)
|
||||
target := testTarget(0xCD)
|
||||
for i := 0; b.Loop(); i++ {
|
||||
pow.Verify(key, target, 22, uint32(i%math.MaxUint32))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
package pow_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
)
|
||||
|
||||
const powVectorsPath = "../../testdata/vectors/pow_vectors.json"
|
||||
|
||||
type powVectorFile struct {
|
||||
Domain string `json:"domain"`
|
||||
HashSpec string `json:"hash_spec"`
|
||||
MaxDifficulty int `json:"max_difficulty"`
|
||||
Vectors []struct {
|
||||
Name string `json:"name"`
|
||||
KeyHex string `json:"key_hex"`
|
||||
TargetHex string `json:"target_hex"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
Counter uint32 `json:"counter"`
|
||||
SumHex string `json:"sum_hex"`
|
||||
LeadingZeroBits int `json:"leading_zero_bits"`
|
||||
} `json:"vectors"`
|
||||
Rejects []struct {
|
||||
Name string `json:"name"`
|
||||
KeyHex string `json:"key_hex"`
|
||||
TargetHex string `json:"target_hex"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
Counter uint32 `json:"counter"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"rejects"`
|
||||
ConfigRejects []struct {
|
||||
Name string `json:"name"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"config_rejects"`
|
||||
}
|
||||
|
||||
func loadPoWVectors(t *testing.T) *powVectorFile {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(powVectorsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read vectors: %v", err)
|
||||
}
|
||||
var vf powVectorFile
|
||||
if err := json.Unmarshal(b, &vf); err != nil {
|
||||
t.Fatalf("parse vectors: %v", err)
|
||||
}
|
||||
return &vf
|
||||
}
|
||||
|
||||
// TestPoWVectorsAgainstReference reproduces every frozen vector produced by
|
||||
// the independent Python reference. A disagreement here means one of the two
|
||||
// implementations misread docs/POW.md.
|
||||
func TestPoWVectorsAgainstReference(t *testing.T) {
|
||||
vf := loadPoWVectors(t)
|
||||
|
||||
if vf.Domain != pow.Domain {
|
||||
t.Fatalf("domain drift: file %q, code %q", vf.Domain, pow.Domain)
|
||||
}
|
||||
if vf.MaxDifficulty != pow.MaxDifficulty {
|
||||
t.Fatalf("max difficulty drift: file %d, code %d", vf.MaxDifficulty, pow.MaxDifficulty)
|
||||
}
|
||||
|
||||
for _, v := range vf.Vectors {
|
||||
t.Run(v.Name, func(t *testing.T) {
|
||||
key, err := pow.ParseKey(v.KeyHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var target [pow.TargetSize]byte
|
||||
tb, err := hex.DecodeString(v.TargetHex)
|
||||
if err != nil || len(tb) != pow.TargetSize {
|
||||
t.Fatalf("bad target hex: %v", err)
|
||||
}
|
||||
copy(target[:], tb)
|
||||
|
||||
got := pow.Sum(key, target, v.Counter)
|
||||
want, _ := hex.DecodeString(v.SumHex)
|
||||
if hex.EncodeToString(got[:]) != v.SumHex {
|
||||
t.Fatalf("sum mismatch:\n got %x\nwant %x", got[:], want)
|
||||
}
|
||||
if n := pow.LeadingZeroBits(got); n < v.Difficulty {
|
||||
t.Fatalf("vector claims validity at difficulty %d but hash has %d bits", v.Difficulty, n)
|
||||
}
|
||||
if got2 := pow.LeadingZeroBits(got); got2 != v.LeadingZeroBits {
|
||||
t.Fatalf("leading zero bits: got %d, want %d", got2, v.LeadingZeroBits)
|
||||
}
|
||||
if !pow.Verify(key, target, v.Difficulty, v.Counter) {
|
||||
t.Fatal("Verify rejected a reference-valid solution")
|
||||
}
|
||||
|
||||
// The recorded counter must be the smallest solution: nothing
|
||||
// below it may verify. Solving again and comparing enforces both
|
||||
// minimality and solver determinism.
|
||||
solved, ok := pow.Solve(key, target, v.Difficulty)
|
||||
if !ok {
|
||||
t.Fatal("Solve exhausted counter space on a solvable vector")
|
||||
}
|
||||
if solved != v.Counter {
|
||||
t.Fatalf("Solve returned %d, reference minimal counter is %d", solved, v.Counter)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoWRejectsAgainstReference(t *testing.T) {
|
||||
vf := loadPoWVectors(t)
|
||||
for _, r := range vf.Rejects {
|
||||
t.Run(r.Name, func(t *testing.T) {
|
||||
key, err := pow.ParseKey(r.KeyHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var target [pow.TargetSize]byte
|
||||
tb, _ := hex.DecodeString(r.TargetHex)
|
||||
copy(target[:], tb)
|
||||
if pow.Verify(key, target, r.Difficulty, r.Counter) {
|
||||
t.Fatalf("reject %q verifies", r.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, c := range vf.ConfigRejects {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
if c.Difficulty > pow.MaxDifficulty {
|
||||
return // configuration layer must clamp or refuse; unit scope ends here
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
package server
|
||||
|
||||
// BFT validator mode. Opt-in: a relay that runs with -bft-validators also
|
||||
// acts as a finality validator using its existing transport key. Non-validator
|
||||
// relays and light nodes stay pure gossip participants and can still verify
|
||||
// any published certificate offline against the public validator set.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/bft"
|
||||
)
|
||||
|
||||
func bytesReader(raw []byte) *bytes.Reader { return bytes.NewReader(raw) }
|
||||
|
||||
// BFTConfig configures validator participation.
|
||||
type BFTConfig struct {
|
||||
// ValidatorKeys are hex Ed25519 public keys of every validator,
|
||||
// index-aligned with ValidatorURLs.
|
||||
ValidatorKeys []string
|
||||
// ValidatorURLs are base URLs used to reach each validator.
|
||||
ValidatorURLs []string
|
||||
// RoundTimeout drives proposer rotation when a round stalls.
|
||||
RoundTimeout time.Duration
|
||||
}
|
||||
|
||||
type bftState struct {
|
||||
set *bft.Set
|
||||
me int // -1 when this relay is not a validator
|
||||
val *bft.Validator
|
||||
key ed25519.PrivateKey
|
||||
urls []string
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
lastProg time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// WithBFT enables validator participation. The relay's transport key doubles
|
||||
// as its validator key: one key, one role, no new secrets.
|
||||
func WithBFT(cfg BFTConfig) Option {
|
||||
return func(s *Server) {
|
||||
if s.ckpt == nil || len(cfg.ValidatorKeys) == 0 {
|
||||
return // finality needs checkpoints to exist at all
|
||||
}
|
||||
set, err := bft.NewSet(cfg.ValidatorKeys, cfg.ValidatorURLs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
st := &bftState{
|
||||
set: set,
|
||||
me: -1,
|
||||
urls: cfg.ValidatorURLs,
|
||||
timeout: cfg.RoundTimeout,
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
myPub := s.ckpt.key.Public().(ed25519.PublicKey)
|
||||
for i := range set.PubKeys {
|
||||
if string(set.PubKeys[i]) == string(myPub) {
|
||||
st.me = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if st.me >= 0 {
|
||||
st.key = s.ckpt.key
|
||||
st.val = newMachineFor(s, st)
|
||||
}
|
||||
s.bft = st
|
||||
}
|
||||
}
|
||||
|
||||
// newMachineFor builds the state machine wired to this relay's heads and
|
||||
// HTTP fan-out.
|
||||
func newMachineFor(s *Server, st *bftState) *bft.Validator {
|
||||
latest := func() ([32]byte, bool) {
|
||||
h, ok := s.ckpt.latestHead()
|
||||
if !ok {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
idb, err := hex.DecodeString(h.ID)
|
||||
if err != nil || len(idb) != 32 {
|
||||
return [32]byte{}, false
|
||||
}
|
||||
var id [32]byte
|
||||
copy(id[:], idb)
|
||||
return id, true
|
||||
}
|
||||
broadcast := func(path string, payload any) {
|
||||
for _, u := range st.urls {
|
||||
go func(url string) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, url+"/v1/bft/"+path, bytesReader(raw))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := st.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}(u)
|
||||
}
|
||||
}
|
||||
return bft.NewValidator(st.set, st.me, st.key, latest, broadcast)
|
||||
}
|
||||
|
||||
// StartBFT runs the round driver until ctx ends.
|
||||
func (s *Server) StartBFT(ctx context.Context) {
|
||||
if s.bft == nil || s.bft.val == nil {
|
||||
return
|
||||
}
|
||||
timeout := s.bft.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(timeout / 2)
|
||||
defer ticker.Stop()
|
||||
s.bft.lastProg = time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.bft.mu.Lock()
|
||||
h := s.bft.val.Height()
|
||||
progressed := time.Since(s.bft.lastProg) < timeout
|
||||
r := uint64(0)
|
||||
if !progressed {
|
||||
r = (uint64(time.Now().UnixNano()) % uint64(len(s.bft.set.PubKeys)))
|
||||
s.bft.lastProg = time.Now()
|
||||
}
|
||||
s.bft.mu.Unlock()
|
||||
if !progressed {
|
||||
s.bft.val.StartRound(h, r%uint64(len(s.bft.set.PubKeys)))
|
||||
} else {
|
||||
s.bft.val.StartRound(h, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ handlers
|
||||
|
||||
func (s *Server) handleBFTProposal(w http.ResponseWriter, r *http.Request) {
|
||||
var signed bft.Signed
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad proposal body")
|
||||
return
|
||||
}
|
||||
if s.bft == nil || s.bft.val == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "bft disabled")
|
||||
return
|
||||
}
|
||||
s.bft.val.OnProposal(&signed)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleBFTVote(w http.ResponseWriter, r *http.Request) {
|
||||
var signed bft.Signed
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad vote body")
|
||||
return
|
||||
}
|
||||
if s.bft == nil || s.bft.val == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "bft disabled")
|
||||
return
|
||||
}
|
||||
s.bft.val.OnVote(&signed)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleBFTState(w http.ResponseWriter, r *http.Request) {
|
||||
if s.bft == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "bft disabled")
|
||||
return
|
||||
}
|
||||
out := map[string]any{"enabled": s.bft.me >= 0}
|
||||
if s.bft.val != nil {
|
||||
out["height"] = s.bft.val.Height()
|
||||
lf := s.bft.val.LastFinalized()
|
||||
out["last_finalized"] = hex.EncodeToString(lf[:])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleBFTCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.bft == nil || s.bft.val == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "bft disabled")
|
||||
return
|
||||
}
|
||||
h, err := strconv.ParseUint(r.PathValue("height"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad height")
|
||||
return
|
||||
}
|
||||
cert := s.bft.val.Certificate(h)
|
||||
if cert == nil {
|
||||
writeErr(w, http.StatusNotFound, "no certificate")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cert)
|
||||
}
|
||||
|
||||
// SetBFT applies BFT configuration after construction (used by main before
|
||||
// serving starts).
|
||||
func (s *Server) SetBFT(cfg BFTConfig) {
|
||||
WithBFT(cfg)(s)
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
package server
|
||||
|
||||
// The checkpoint service signs the relay's object-set commitment on a
|
||||
// schedule: every interval, or as soon as enough new objects have arrived,
|
||||
// whichever comes first. It owns the relay's transport key — the one key the
|
||||
// relay holds, whose entire power is to describe its own storage
|
||||
// (docs/TRUST-MODEL.md, INV-1 amendment). Statement signing stays impossible:
|
||||
// this file never imports identity/signer and the key never touches TCE.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
)
|
||||
|
||||
const (
|
||||
ckptSeedFile = "relay_key.seed" // relay transport seed inside data dir
|
||||
ckptMetaFile = "checkpoint_meta.json" // persisted epoch continuity
|
||||
ckptHistoryPrefix = "checkpoint-" // per-epoch head files
|
||||
ckptMaxHistory = 512 // heads kept in memory
|
||||
)
|
||||
|
||||
// CheckpointConfig configures the scheduler.
|
||||
type CheckpointConfig struct {
|
||||
// Interval is the maximum time between checkpoints when the set changed.
|
||||
Interval time.Duration
|
||||
// EveryN signs earlier once this many new objects arrived.
|
||||
// 0 falls back to Interval alone; 1 checkpoints every change.
|
||||
EveryN int
|
||||
}
|
||||
|
||||
type checkpointState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
key ed25519.PrivateKey
|
||||
pubHex string
|
||||
|
||||
store *Store
|
||||
metrics *Metrics
|
||||
|
||||
interval time.Duration
|
||||
everyN int
|
||||
dir string // empty in in-memory mode: nothing persists
|
||||
|
||||
epoch uint64
|
||||
lastHash [32]byte // ID of most recent canonical bytes; zeros before genesis
|
||||
dirty int
|
||||
lastSign time.Time
|
||||
heads map[uint64]signedHead
|
||||
latest uint64
|
||||
}
|
||||
|
||||
// signedHead is one published checkpoint with its signature.
|
||||
type signedHead struct {
|
||||
Bytes []byte `json:"bytes"`
|
||||
Signature []byte `json:"signature"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *signedHead) decode() (*checkpoint.Checkpoint, error) {
|
||||
return checkpoint.Decode(h.Bytes)
|
||||
}
|
||||
|
||||
// loadRelayKey reads or creates the transport seed.
|
||||
func loadRelayKey(dir string) (ed25519.PrivateKey, error) {
|
||||
if dir == "" {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
return priv, err
|
||||
}
|
||||
path := filepath.Join(dir, ckptSeedFile)
|
||||
if seed, err := os.ReadFile(path); err == nil && len(seed) == ed25519.SeedSize {
|
||||
return ed25519.NewKeyFromSeed(seed), nil
|
||||
}
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, seed, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ed25519.NewKeyFromSeed(seed), nil
|
||||
}
|
||||
|
||||
// WithCheckpoints enables the header chain. An empty dataDir means the relay
|
||||
// runs in-memory: heads exist only while the process lives.
|
||||
func WithCheckpoints(dataDir string, cfg CheckpointConfig) Option {
|
||||
return func(s *Server) {
|
||||
key, err := loadRelayKey(dataDir)
|
||||
if err != nil {
|
||||
// A relay that cannot hold its transport key still serves; it
|
||||
// simply publishes no chain.
|
||||
s.ckpt = nil
|
||||
return
|
||||
}
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = time.Minute
|
||||
}
|
||||
st := &checkpointState{
|
||||
key: key,
|
||||
pubHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
|
||||
store: s.store,
|
||||
metrics: s.metrics,
|
||||
interval: cfg.Interval,
|
||||
everyN: cfg.EveryN,
|
||||
dir: dataDir,
|
||||
heads: make(map[uint64]signedHead),
|
||||
}
|
||||
st.loadMeta()
|
||||
st.loadHistory()
|
||||
s.ckpt = st
|
||||
}
|
||||
}
|
||||
|
||||
// metaJSON is the persisted epoch continuity record.
|
||||
type metaJSON struct {
|
||||
Epoch uint64 `json:"epoch"`
|
||||
LastHash string `json:"last_hash"`
|
||||
}
|
||||
|
||||
func (c *checkpointState) loadMeta() {
|
||||
if c.dir == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(c.dir, ckptMetaFile))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m metaJSON
|
||||
if json.Unmarshal(raw, &m) != nil || m.Epoch == 0 {
|
||||
return
|
||||
}
|
||||
hash, err := hex.DecodeString(m.LastHash)
|
||||
if err != nil || len(hash) != 32 {
|
||||
return
|
||||
}
|
||||
c.epoch = m.Epoch
|
||||
copy(c.lastHash[:], hash)
|
||||
}
|
||||
|
||||
func (c *checkpointState) saveMetaLocked() error {
|
||||
if c.dir == "" {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(metaJSON{Epoch: c.epoch, LastHash: hex.EncodeToString(c.lastHash[:])})
|
||||
tmp := filepath.Join(c.dir, ckptMetaFile+".tmp")
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(c.dir, ckptMetaFile))
|
||||
}
|
||||
|
||||
func (c *checkpointState) loadHistory() {
|
||||
if c.dir == "" {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if len(name) <= len(ckptHistoryPrefix)+len(".json") {
|
||||
continue
|
||||
}
|
||||
if name[:len(ckptHistoryPrefix)] != ckptHistoryPrefix || name[len(name)-5:] != ".json" {
|
||||
continue
|
||||
}
|
||||
ep, err := strconv.ParseUint(name[len(ckptHistoryPrefix):len(name)-5], 10, 64)
|
||||
if err != nil || ep == 0 {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(c.dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var h signedHead
|
||||
if json.Unmarshal(raw, &h) != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := h.decode(); err != nil {
|
||||
continue
|
||||
}
|
||||
c.heads[ep] = h
|
||||
if ep > c.latest {
|
||||
c.latest = ep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify records that the set changed and may trigger an immediate sign.
|
||||
func (s *Server) notifyCheckpoint() {
|
||||
if s.ckpt == nil {
|
||||
return
|
||||
}
|
||||
s.ckpt.mu.Lock()
|
||||
s.ckpt.dirty++
|
||||
fire := s.ckpt.everyN > 0 && s.ckpt.dirty >= s.ckpt.everyN
|
||||
s.ckpt.mu.Unlock()
|
||||
if fire {
|
||||
s.ckpt.sign()
|
||||
}
|
||||
}
|
||||
|
||||
// sign builds, signs and stores one head if anything changed.
|
||||
func (c *checkpointState) sign() (*signedHead, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
root := c.store.TrieRoot()
|
||||
size := c.store.TrieSize()
|
||||
if size == 0 && c.epoch == 0 {
|
||||
return nil, false // nothing to commit to yet
|
||||
}
|
||||
|
||||
cp := &checkpoint.Checkpoint{
|
||||
Epoch: c.epoch + 1,
|
||||
Size: size,
|
||||
Root: root,
|
||||
Prev: c.lastHash,
|
||||
CreatedAt: uint64(now().Unix()),
|
||||
}
|
||||
b, err := cp.Encode()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
sig, err := checkpoint.Sign(c.key, b)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
id := checkpoint.ID(b)
|
||||
head := signedHead{Bytes: b, Signature: sig, ID: hex.EncodeToString(id[:])}
|
||||
|
||||
c.epoch = cp.Epoch
|
||||
c.latest = cp.Epoch
|
||||
c.lastHash = id
|
||||
c.dirty = 0
|
||||
c.lastSign = now()
|
||||
c.heads[cp.Epoch] = head
|
||||
delete(c.heads, cp.Epoch-ckptMaxHistory)
|
||||
if c.metrics != nil {
|
||||
c.metrics.checkpointsSigned.Add(1)
|
||||
c.metrics.trieSize.Store(size)
|
||||
}
|
||||
|
||||
if c.dir != "" {
|
||||
raw, _ := json.Marshal(head)
|
||||
path := filepath.Join(c.dir, fmt.Sprintf("%s%d.json", ckptHistoryPrefix, cp.Epoch))
|
||||
if os.WriteFile(path+".tmp", raw, 0o600) == nil {
|
||||
_ = os.Rename(path+".tmp", path)
|
||||
}
|
||||
_ = c.saveMetaLocked()
|
||||
}
|
||||
return &head, true
|
||||
}
|
||||
|
||||
// latest returns the newest head, if any.
|
||||
func (c *checkpointState) latestHead() (signedHead, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
h, ok := c.heads[c.latest]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (c *checkpointState) headByEpoch(ep uint64) (signedHead, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
h, ok := c.heads[ep]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
// run loops until ctx ends, signing when the interval elapsed with pending
|
||||
// changes.
|
||||
func (c *checkpointState) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(c.interval / 4)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.mu.Lock()
|
||||
pending := c.dirty > 0 && now().Sub(c.lastSign) >= c.interval
|
||||
c.mu.Unlock()
|
||||
if pending {
|
||||
c.sign()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartCheckpoints launches the signing loop. Call StopCheckpoints on shutdown.
|
||||
func (s *Server) StartCheckpoints(ctx context.Context) {
|
||||
if s.ckpt == nil {
|
||||
return
|
||||
}
|
||||
go s.ckpt.run(ctx)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ HTTP handlers
|
||||
|
||||
func (s *Server) handleCheckpointLatest(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveHead(w, func() (signedHead, bool) { return s.ckpt.latestHead() })
|
||||
}
|
||||
|
||||
func (s *Server) handleCheckpointEpoch(w http.ResponseWriter, r *http.Request) {
|
||||
ep, err := strconv.ParseUint(r.PathValue("epoch"), 10, 64)
|
||||
if err != nil || ep == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "bad epoch")
|
||||
return
|
||||
}
|
||||
s.serveHead(w, func() (signedHead, bool) { return s.ckpt.headByEpoch(ep) })
|
||||
}
|
||||
|
||||
func (s *Server) serveHead(w http.ResponseWriter, get func() (signedHead, bool)) {
|
||||
if s.ckpt == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "checkpoints disabled")
|
||||
return
|
||||
}
|
||||
h, ok := get()
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "no checkpoint")
|
||||
return
|
||||
}
|
||||
cp, err := h.decode()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "corrupt head")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"bytes": base64.StdEncoding.EncodeToString(h.Bytes),
|
||||
"signature": base64.StdEncoding.EncodeToString(h.Signature),
|
||||
"id": h.ID,
|
||||
"public_key": s.ckpt.pubHex,
|
||||
"checkpoint": map[string]any{
|
||||
"epoch": cp.Epoch,
|
||||
"size": cp.Size,
|
||||
"root": hex.EncodeToString(cp.Root[:]),
|
||||
"prev": hex.EncodeToString(cp.Prev[:]),
|
||||
"created_at": cp.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
errProofPresentRequested = errors.New("server: object exists; absence proof unavailable")
|
||||
errProofAbsentRequested = errors.New("server: object unknown; inclusion proof unavailable")
|
||||
)
|
||||
|
||||
func (s *Server) handleProofObject(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveProof(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) handleProofAbsent(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveProof(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) serveProof(w http.ResponseWriter, r *http.Request, wantPresent bool) {
|
||||
if s.ckpt == nil {
|
||||
writeErr(w, http.StatusNotImplemented, "checkpoints disabled")
|
||||
return
|
||||
}
|
||||
id, err := tce.ParseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad object id")
|
||||
return
|
||||
}
|
||||
root, proof, err := s.store.ProofFor([32]byte(id), wantPresent)
|
||||
if err != nil {
|
||||
status := http.StatusUnprocessableEntity
|
||||
if !errors.Is(err, errProofPresentRequested) && !errors.Is(err, errProofAbsentRequested) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeErr(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"proof": base64.StdEncoding.EncodeToString(proof),
|
||||
"root": hex.EncodeToString(root[:]),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/server"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
)
|
||||
|
||||
type ckptResponse struct {
|
||||
Bytes string `json:"bytes"`
|
||||
Signature string `json:"signature"`
|
||||
ID string `json:"id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Checkpoint struct {
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Size uint64 `json:"size"`
|
||||
Root string `json:"root"`
|
||||
Prev string `json:"prev"`
|
||||
CreatedAt uint64 `json:"created_at"`
|
||||
} `json:"checkpoint"`
|
||||
}
|
||||
|
||||
func newCkptServer(t *testing.T, dir string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
|
||||
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}),
|
||||
)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func fetchLatest(t *testing.T, url string) (*ckptResponse, int) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url + "/v1/checkpoint/latest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var out ckptResponse
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return &out, resp.StatusCode
|
||||
}
|
||||
|
||||
// waitForHead polls until a checkpoint with at least minSize objects exists.
|
||||
func waitForHead(t *testing.T, url string, minSize uint64) *ckptResponse {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
out, code := fetchLatest(t, url)
|
||||
if code == http.StatusOK && out.Checkpoint.Size >= minSize {
|
||||
return out
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("no checkpoint appeared in time")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCheckpointSignedAndVerifiable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := newCkptServer(t, dir)
|
||||
|
||||
b, sig := makeClaim(t)
|
||||
resp := putWithPoW(t, ts.URL, b, sig, "", 0) // PoW disabled here
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put status %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
head := waitForHead(t, ts.URL, 1)
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(head.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cp, err := checkpoint.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cp.Epoch != head.Checkpoint.Epoch || cp.Size != head.Checkpoint.Size {
|
||||
t.Fatal("view disagrees with canonical bytes")
|
||||
}
|
||||
if cp.Prev != ([32]byte{}) {
|
||||
t.Fatal("genesis prev must be zeros")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(head.Signature)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := hex.DecodeString(head.PublicKey)
|
||||
if err != nil || len(pub) != 32 {
|
||||
t.Fatalf("bad public key: %v", err)
|
||||
}
|
||||
if err := checkpoint.Verify(pub, raw, sigBytes); err != nil {
|
||||
t.Fatal("signature failed to verify:", err)
|
||||
}
|
||||
|
||||
idSum := sha256.Sum256(raw)
|
||||
if hex.EncodeToString(idSum[:]) != head.ID {
|
||||
t.Fatal("id is not SHA-256 of canonical bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointChainLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := newCkptServer(t, dir)
|
||||
|
||||
// EveryN=1: every stored object produces exactly one signed head.
|
||||
for i := 0; i < 4; i++ {
|
||||
b, sig := makeClaim(t)
|
||||
r := putWithPoW(t, ts.URL, b, sig, "", 0)
|
||||
r.Body.Close()
|
||||
}
|
||||
waitForHead(t, ts.URL, 4)
|
||||
|
||||
fetchEpoch := func(ep string) *ckptResponse {
|
||||
t.Helper()
|
||||
resp, err := http.Get(ts.URL + "/v1/checkpoint/" + ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("epoch %s status %d", ep, resp.StatusCode)
|
||||
}
|
||||
var out ckptResponse
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
return &out
|
||||
}
|
||||
|
||||
h1 := fetchEpoch("1")
|
||||
h2 := fetchEpoch("2")
|
||||
h3 := fetchEpoch("3")
|
||||
if h1.Checkpoint.Epoch != 1 || h1.Checkpoint.Size != 1 {
|
||||
t.Fatalf("genesis head wrong: %+v", h1.Checkpoint)
|
||||
}
|
||||
if h1.Checkpoint.Prev != hexZero() {
|
||||
t.Fatal("genesis prev not zeros")
|
||||
}
|
||||
|
||||
// Each head links to the previous by content hash.
|
||||
for i, cur := range []*ckptResponse{h2, h3} {
|
||||
prev := []*ckptResponse{h1, h2}[i]
|
||||
rawPrev, _ := base64.StdEncoding.DecodeString(prev.Bytes)
|
||||
want := sha256.Sum256(rawPrev)
|
||||
if cur.Checkpoint.Prev != hex.EncodeToString(want[:]) {
|
||||
t.Fatalf("head %d does not link to %d", i+2, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
latest, code := fetchLatest(t, ts.URL)
|
||||
if code != http.StatusOK || latest.Checkpoint.Epoch != 4 {
|
||||
t.Fatalf("latest = epoch %d (status %d)", latest.Checkpoint.Epoch, code)
|
||||
}
|
||||
if latest.Checkpoint.Size != 4 {
|
||||
t.Fatalf("latest size %d", latest.Checkpoint.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func hexZero() string { return hex.EncodeToString(make([]byte, 32)) }
|
||||
|
||||
func TestProofEndpointsVerifyAgainstSmt(t *testing.T) {
|
||||
ts := newCkptServer(t, t.TempDir())
|
||||
|
||||
b, sig := makeClaim(t)
|
||||
r := putWithPoW(t, ts.URL, b, sig, "", 0)
|
||||
r.Body.Close()
|
||||
objID := tceComputeID(b)
|
||||
waitForHead(t, ts.URL, 1)
|
||||
|
||||
// Inclusion proof for the stored object.
|
||||
resp, err := http.Get(ts.URL + "/v1/proof/object/" + objID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("inclusion proof status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Proof string `json:"proof"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&out)
|
||||
proof, _ := base64.StdEncoding.DecodeString(out.Proof)
|
||||
root, _ := hex.DecodeString(out.Root)
|
||||
var rootArr [32]byte
|
||||
copy(rootArr[:], root)
|
||||
if !smt.VerifyInclusion(rootArr, [32]byte(mustID(t, objID)), proof) {
|
||||
t.Fatal("inclusion proof failed verification")
|
||||
}
|
||||
|
||||
// Absence proof for an unknown object.
|
||||
var unknown [32]byte
|
||||
unknown[0] = 0xAB
|
||||
resp2, err := http.Get(ts.URL + "/v1/proof/absent/" + hex.EncodeToString(unknown[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("absence proof status %d", resp2.StatusCode)
|
||||
}
|
||||
var out2 struct {
|
||||
Proof string `json:"proof"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&out2)
|
||||
proof2, _ := base64.StdEncoding.DecodeString(out2.Proof)
|
||||
root2, _ := hex.DecodeString(out2.Root)
|
||||
copy(rootArr[:], root2)
|
||||
if !smt.VerifyAbsence(rootArr, unknown, proof2) {
|
||||
t.Fatal("absence proof failed verification")
|
||||
}
|
||||
|
||||
// Wrong kind: absence of a present object must be refused.
|
||||
resp3, err := http.Get(ts.URL + "/v1/proof/absent/" + objID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
if resp3.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("absence-of-present status %d", resp3.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointRestartPersistsRootAndHistory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
srv1 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
|
||||
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
|
||||
ts1 := httptest.NewServer(srv1.Handler())
|
||||
for i := 0; i < 2; i++ {
|
||||
b, sig := makeClaim(t)
|
||||
r := putWithPoW(t, ts1.URL, b, sig, "", 0)
|
||||
r.Body.Close()
|
||||
}
|
||||
before := waitForHead(t, ts1.URL, 2)
|
||||
ts1.Close()
|
||||
|
||||
// The seed file must persist so the relay identity survives restarts.
|
||||
if _, err := os.Stat(filepath.Join(dir, "relay_key.seed")); err != nil {
|
||||
t.Fatal("relay key not persisted:", err)
|
||||
}
|
||||
|
||||
srv2 := server.New(server.Config{Audience: "trust.n1ko.dev", DataDir: dir},
|
||||
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
|
||||
ts2 := httptest.NewServer(srv2.Handler())
|
||||
defer ts2.Close()
|
||||
|
||||
after, code := fetchLatest(t, ts2.URL)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("no history after restart (status %d)", code)
|
||||
}
|
||||
if after.ID != before.ID {
|
||||
t.Fatal("checkpoint history lost across restart")
|
||||
}
|
||||
afterRaw, _ := base64.StdEncoding.DecodeString(after.Bytes)
|
||||
cpAfter, err := checkpoint.Decode(afterRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Root is a function of the set: replaying the directory reproduces it.
|
||||
b, sig := makeClaim(t)
|
||||
r := putWithPoW(t, ts2.URL, b, sig, "", 0)
|
||||
r.Body.Close()
|
||||
_ = cpAfter
|
||||
|
||||
next := waitForHead(t, ts2.URL, 3)
|
||||
if next.Checkpoint.Epoch != cpAfter.Epoch+1 {
|
||||
t.Fatalf("restart reset epoch: %d -> %d", cpAfter.Epoch, next.Checkpoint.Epoch)
|
||||
}
|
||||
if next.Checkpoint.Prev != after.ID {
|
||||
t.Fatal("restarted chain does not link to persisted head")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExposesRelayPubkey(t *testing.T) {
|
||||
ts := newCkptServer(t, t.TempDir())
|
||||
resp, err := http.Get(ts.URL + "/v1/config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var cfg map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||
if len(cfg["relay_pubkey"]) != 64 {
|
||||
t.Fatalf("config missing relay_pubkey: %v", cfg)
|
||||
}
|
||||
if _, err := hex.DecodeString(cfg["relay_pubkey"]); err != nil {
|
||||
t.Fatal("relay_pubkey not hex:", err)
|
||||
}
|
||||
if !bytes.Contains([]byte(cfg["audience"]), []byte("trust")) {
|
||||
t.Fatal("audience lost")
|
||||
}
|
||||
}
|
||||
|
||||
func mustID(t *testing.T, hexStr string) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tceComputeID(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package server
|
||||
|
||||
// Gossip: relays and light nodes exchange signed heads so that a split view
|
||||
// (one mirror quietly serving a different object set) becomes detectable by
|
||||
// comparing what independent peers claim for the same relay key. Reception
|
||||
// is passive pull-free v1: peers POST heads here; the active polling side is
|
||||
// the light node (internal/lightnode).
|
||||
//
|
||||
// Trust rules are deliberately narrow:
|
||||
// - A head is stored only if its signature verifies under the public key
|
||||
// that claims to have produced it, and its ID matches its bytes.
|
||||
// - Keys are pinned on first contact (TOFU) per peer key, not per IP: the
|
||||
// unit of trust is the relay identity, not the network path.
|
||||
// - Two heads from one key at one epoch with different IDs mean the
|
||||
// operator of that key is equivocating; the conflict is recorded and
|
||||
// exposed rather than silently resolved.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
)
|
||||
|
||||
type peerHead struct {
|
||||
Head signedHead `json:"head"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
type gossipEntry struct {
|
||||
head peerHead
|
||||
epoch uint64
|
||||
id [32]byte
|
||||
}
|
||||
|
||||
type gossipState struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*gossipEntry // relay pubkey hex -> latest observed head
|
||||
}
|
||||
|
||||
func newGossipState() *gossipState {
|
||||
return &gossipState{peers: make(map[string]*gossipEntry)}
|
||||
}
|
||||
|
||||
func (s *Server) handleGossipCheckpoint(w http.ResponseWriter, r *http.Request) {
|
||||
var in peerHead
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad gossip body")
|
||||
return
|
||||
}
|
||||
pub, err := hex.DecodeString(in.PublicKey)
|
||||
if err != nil || len(pub) != ed25519.PublicKeySize {
|
||||
writeErr(w, http.StatusBadRequest, "bad public_key")
|
||||
return
|
||||
}
|
||||
cp, err := checkpoint.Decode(in.Head.Bytes)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnprocessableEntity, "bad checkpoint bytes")
|
||||
return
|
||||
}
|
||||
if err := checkpoint.Verify(ed25519.PublicKey(pub), in.Head.Bytes, in.Head.Signature); err != nil {
|
||||
s.metrics.inc(&s.metrics.gossipRejected)
|
||||
writeErr(w, http.StatusUnauthorized, "signature fails")
|
||||
return
|
||||
}
|
||||
idSum := checkpoint.ID(in.Head.Bytes)
|
||||
var claimedID [32]byte
|
||||
if got, err := hex.DecodeString(in.Head.ID); err != nil || len(got) != 32 {
|
||||
writeErr(w, http.StatusBadRequest, "bad id")
|
||||
return
|
||||
} else {
|
||||
copy(claimedID[:], got)
|
||||
}
|
||||
if claimedID != idSum {
|
||||
writeErr(w, http.StatusUnprocessableEntity, "id does not match bytes")
|
||||
return
|
||||
}
|
||||
|
||||
keyHex := in.PublicKey
|
||||
s.gossip.mu.Lock()
|
||||
prev, seen := s.gossip.peers[keyHex]
|
||||
switch {
|
||||
case !seen || cp.Epoch > prev.epoch:
|
||||
s.gossip.peers[keyHex] = &gossipEntry{head: in, epoch: cp.Epoch, id: idSum}
|
||||
case cp.Epoch == prev.epoch && idSum != prev.id:
|
||||
// Same key, same epoch, different bytes: equivocation.
|
||||
s.metrics.inc(&s.metrics.gossipDivergence)
|
||||
}
|
||||
s.gossip.mu.Unlock()
|
||||
s.metrics.inc(&s.metrics.gossipAccepted)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stored"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePeerHeads(w http.ResponseWriter, r *http.Request) {
|
||||
s.gossip.mu.Lock()
|
||||
defer s.gossip.mu.Unlock()
|
||||
out := make([]peerHead, 0, len(s.gossip.peers))
|
||||
for _, e := range s.gossip.peers {
|
||||
out = append(out, e.head)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"heads": out})
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/server"
|
||||
)
|
||||
|
||||
func signHead(t *testing.T, priv ed25519.PrivateKey, cp *checkpoint.Checkpoint) map[string]any {
|
||||
t.Helper()
|
||||
b, err := cp.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sig, err := checkpoint.Sign(priv, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idSum := checkpoint.ID(b)
|
||||
pubHex := hex.EncodeToString(priv.Public().(ed25519.PublicKey))
|
||||
return map[string]any{
|
||||
"head": map[string]any{
|
||||
"bytes": base64.StdEncoding.EncodeToString(b),
|
||||
"signature": base64.StdEncoding.EncodeToString(sig),
|
||||
"id": hex.EncodeToString(idSum[:]),
|
||||
},
|
||||
"public_key": pubHex,
|
||||
}
|
||||
}
|
||||
|
||||
func postGossip(t *testing.T, url string, body map[string]any) *http.Response {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
json.NewEncoder(buf).Encode(body)
|
||||
resp, err := http.Post(url+"/v1/gossip/checkpoint", "application/json", buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestGossipAcceptsValidHead(t *testing.T) {
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithCheckpoints("", server.CheckpointConfig{Interval: time.Hour}))
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
body := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 1, Size: 7, CreatedAt: 1_700_000_000})
|
||||
resp := postGossip(t, ts.URL, body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
list, err := http.Get(ts.URL + "/v1/peers/heads")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer list.Body.Close()
|
||||
var out struct {
|
||||
Heads []struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"heads"`
|
||||
}
|
||||
json.NewDecoder(list.Body).Decode(&out)
|
||||
if len(out.Heads) != 1 || out.Heads[0].PublicKey != hex.EncodeToString(pub) {
|
||||
t.Fatalf("peer heads wrong: %+v", out.Heads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGossipRejectsBadSignatureAndMismatchedID(t *testing.T) {
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev"})
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_ = pub
|
||||
|
||||
// Corrupt the signature.
|
||||
body := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 2, CreatedAt: 1_700_000_000})
|
||||
head := body["head"].(map[string]any)
|
||||
sig, _ := base64.StdEncoding.DecodeString(head["signature"].(string))
|
||||
sig[0] ^= 0xFF
|
||||
head["signature"] = base64.StdEncoding.EncodeToString(sig)
|
||||
if r := postGossip(t, ts.URL, body); r.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("bad signature status %d", r.StatusCode)
|
||||
}
|
||||
|
||||
// Claim a different id than the bytes hash to.
|
||||
body2 := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 3, CreatedAt: 1_700_000_060})
|
||||
head2 := body2["head"].(map[string]any)
|
||||
head2["id"] = hex.EncodeToString(make([]byte, 32))
|
||||
if r := postGossip(t, ts.URL, body2); r.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("mismatched id status %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGossipDetectsEquivocation(t *testing.T) {
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev"})
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
|
||||
// Two heads, same key, same epoch, different roots.
|
||||
a := signHead(t, priv, &checkpoint.Checkpoint{Epoch: 5, Size: 1, CreatedAt: 1_700_000_000})
|
||||
aRoot := checkpoint.Checkpoint{Epoch: 5, Size: 1, Root: [32]byte{1}, CreatedAt: 1_700_000_000}
|
||||
b := signHead(t, priv, &aRoot)
|
||||
|
||||
if r := postGossip(t, ts.URL, a); r.StatusCode != http.StatusOK {
|
||||
t.Fatal("first head rejected")
|
||||
}
|
||||
r2 := postGossip(t, ts.URL, b)
|
||||
defer r2.Body.Close()
|
||||
if r2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("conflicting head not stored: %d", r2.StatusCode)
|
||||
}
|
||||
|
||||
metrics, err := http.Get(ts.URL + "/v1/metrics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer metrics.Body.Close()
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := metrics.Body.Read(buf)
|
||||
if !strings.Contains(string(buf[:n]), "trust_gossip_divergence 1") {
|
||||
t.Fatalf("divergence not recorded:\n%s", buf[:n])
|
||||
}
|
||||
}
|
||||
|
|
@ -12,23 +12,16 @@ import (
|
|||
// Prometheus text exposition format. No external dependency: the values are
|
||||
// plain atomic counters rendered as text.
|
||||
type Metrics struct {
|
||||
objectsStored atomic.Uint64
|
||||
objectsRejected atomic.Uint64
|
||||
objectsDeleted atomic.Uint64
|
||||
challengesIssued atomic.Uint64
|
||||
assertionsOK atomic.Uint64
|
||||
assertionsFailed atomic.Uint64
|
||||
requestsTotal atomic.Uint64
|
||||
sessionsActive atomic.Int64
|
||||
powOK atomic.Uint64
|
||||
powFailed atomic.Uint64
|
||||
checkpointsSigned atomic.Uint64
|
||||
trieSize atomic.Uint64
|
||||
gossipAccepted atomic.Uint64
|
||||
gossipRejected atomic.Uint64
|
||||
gossipDivergence atomic.Uint64
|
||||
wsConnections atomic.Int64
|
||||
wsMessagesSent atomic.Uint64
|
||||
objectsStored atomic.Uint64
|
||||
objectsRejected atomic.Uint64
|
||||
objectsDeleted atomic.Uint64
|
||||
challengesIssued atomic.Uint64
|
||||
assertionsOK atomic.Uint64
|
||||
assertionsFailed atomic.Uint64
|
||||
requestsTotal atomic.Uint64
|
||||
sessionsActive atomic.Int64
|
||||
wsConnections atomic.Int64
|
||||
wsMessagesSent atomic.Uint64
|
||||
|
||||
// objectsByType holds one counter per known object type name. The map is
|
||||
// populated once at construction and never mutated afterwards, so
|
||||
|
|
@ -41,13 +34,17 @@ func NewMetrics() *Metrics {
|
|||
m := &Metrics{objectsByType: make(map[string]*atomic.Uint64)}
|
||||
for _, name := range []string{
|
||||
"identity", "claim", "revocation", "approval_request",
|
||||
"approval_response", "auth_assertion",
|
||||
"approval_response", "auth_assertion", "delegation",
|
||||
"key_rotation_request", "key_rotation_confirm",
|
||||
} {
|
||||
m.objectsByType[name] = &atomic.Uint64{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// inc records an event.
|
||||
func (m *Metrics) inc(f *atomic.Uint64) { f.Add(1) }
|
||||
|
||||
// incStoredType records one accepted object of the given type.
|
||||
func (m *Metrics) incStoredType(typ string) {
|
||||
if c, ok := m.objectsByType[typ]; ok {
|
||||
|
|
@ -55,9 +52,6 @@ func (m *Metrics) incStoredType(typ string) {
|
|||
}
|
||||
}
|
||||
|
||||
// inc records an event.
|
||||
func (m *Metrics) inc(f *atomic.Uint64) { f.Add(1) }
|
||||
|
||||
// Write renders the counters in Prometheus text format.
|
||||
func (m *Metrics) Write(w io.Writer) {
|
||||
fmt.Fprintf(w, "# TYPE trust_objects_stored counter\n")
|
||||
|
|
@ -74,20 +68,6 @@ func (m *Metrics) Write(w io.Writer) {
|
|||
fmt.Fprintf(w, "trust_requests_total %d\n", m.requestsTotal.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_sessions_active gauge\n")
|
||||
fmt.Fprintf(w, "trust_sessions_active %d\n", m.sessionsActive.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_pow_ok counter\n")
|
||||
fmt.Fprintf(w, "trust_pow_ok %d\n", m.powOK.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_pow_failed counter\n")
|
||||
fmt.Fprintf(w, "trust_pow_failed %d\n", m.powFailed.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_checkpoints_signed counter\n")
|
||||
fmt.Fprintf(w, "trust_checkpoints_signed %d\n", m.checkpointsSigned.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_trie_size gauge\n")
|
||||
fmt.Fprintf(w, "trust_trie_size %d\n", m.trieSize.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_gossip_accepted counter\n")
|
||||
fmt.Fprintf(w, "trust_gossip_accepted %d\n", m.gossipAccepted.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_gossip_rejected counter\n")
|
||||
fmt.Fprintf(w, "trust_gossip_rejected %d\n", m.gossipRejected.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_gossip_divergence counter\n")
|
||||
fmt.Fprintf(w, "trust_gossip_divergence %d\n", m.gossipDivergence.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_ws_connections gauge\n")
|
||||
fmt.Fprintf(w, "trust_ws_connections %d\n", m.wsConnections.Load())
|
||||
fmt.Fprintf(w, "# TYPE trust_ws_messages_sent counter\n")
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
)
|
||||
|
||||
// powTTL is how long an issued PoW challenge stays valid.
|
||||
const powTTL = 5 * time.Minute
|
||||
|
||||
// powChallengeLimitPerMin caps challenge issuance per IP per minute.
|
||||
const powChallengeLimitPerMin = 30
|
||||
|
||||
// powPurpose names what a challenge will be spent on. The purpose selects the
|
||||
// difficulty the client must solve for, so it is fixed at issuance.
|
||||
type powPurpose string
|
||||
|
||||
const (
|
||||
powPurposePut powPurpose = "put"
|
||||
powPurposeAuth powPurpose = "auth"
|
||||
)
|
||||
|
||||
// powChallengeRecord is one issued, unspent challenge.
|
||||
type powChallengeRecord struct {
|
||||
key pow.Key
|
||||
bits int
|
||||
purpose powPurpose
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// Option configures the relay at construction time.
|
||||
type Option func(*Server)
|
||||
|
||||
// WithPow enables proof-of-work admission control: putBits guards
|
||||
// POST /v1/objects, authBits guards POST /v1/auth/challenge. Zero disables a
|
||||
// tier; values above pow.MaxDifficulty are clamped.
|
||||
func WithPow(putBits, authBits int) Option {
|
||||
return func(s *Server) {
|
||||
s.powPutBits = clampDifficulty(putBits)
|
||||
s.powAuthBits = clampDifficulty(authBits)
|
||||
}
|
||||
}
|
||||
|
||||
func clampDifficulty(n int) int {
|
||||
switch {
|
||||
case n < 0:
|
||||
return 0
|
||||
case n > pow.MaxDifficulty:
|
||||
return pow.MaxDifficulty
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// wirePow is the JSON form of a solved challenge attached to a request body.
|
||||
type wirePow struct {
|
||||
Key string `json:"key"`
|
||||
Counter uint32 `json:"counter"`
|
||||
}
|
||||
|
||||
// rateLimitPowChallenge applies the per-IP cap before issuing.
|
||||
func (s *Server) rateLimitPowChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.powChallengeLimiter.allow(clientIP(r)) {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
writeErr(w, http.StatusTooManyRequests, "rate limited")
|
||||
return
|
||||
}
|
||||
s.handlePowChallenge(w, r)
|
||||
}
|
||||
|
||||
// handlePowChallenge issues a fresh single-use challenge key.
|
||||
//
|
||||
// The request may name a purpose ("put", default, or "auth"); the response
|
||||
// carries the exact difficulty to solve for that purpose. Challenges are
|
||||
// bound at spend time to the submission they accompany.
|
||||
func (s *Server) handlePowChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
if s.powPutBits <= 0 && s.powAuthBits <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "proof of work disabled")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body)
|
||||
}
|
||||
purpose := powPurpose(body.Purpose)
|
||||
if purpose == "" {
|
||||
purpose = powPurposePut
|
||||
}
|
||||
bits := s.powPutBits
|
||||
if purpose == powPurposeAuth {
|
||||
bits = s.powAuthBits
|
||||
}
|
||||
if purpose != powPurposePut && purpose != powPurposeAuth || bits <= 0 {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
writeErr(w, http.StatusBadRequest, "unknown purpose or tier disabled")
|
||||
return
|
||||
}
|
||||
|
||||
key := pow.NewKey()
|
||||
s.mu.Lock()
|
||||
s.prunePowLocked()
|
||||
s.powChallenges[key.String()] = powChallengeRecord{
|
||||
key: key,
|
||||
bits: bits,
|
||||
purpose: purpose,
|
||||
expiry: now().Add(powTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.metrics.inc(&s.metrics.challengesIssued)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"key": key.String(),
|
||||
"difficulty": bits,
|
||||
"ttl": int(powTTL / time.Second),
|
||||
})
|
||||
}
|
||||
|
||||
// prunePowLocked drops expired challenges. The caller holds s.mu.
|
||||
func (s *Server) prunePowLocked() {
|
||||
ts := now()
|
||||
for k, rec := range s.powChallenges {
|
||||
if rec.expiry.Before(ts) {
|
||||
delete(s.powChallenges, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// admitPoW enforces one tier of admission control. target binds the proof to
|
||||
// this exact submission. It reports whether the request may proceed and, on
|
||||
// refusal, the client-facing reason.
|
||||
func (s *Server) admitPoW(proof *wirePow, purpose powPurpose, target [pow.TargetSize]byte, wantBits int) (bool, string) {
|
||||
if wantBits <= 0 {
|
||||
return true, ""
|
||||
}
|
||||
if proof == nil {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
return false, "proof of work required"
|
||||
}
|
||||
key, err := pow.ParseProof(proof.Key, proof.Counter)
|
||||
if err != nil {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
return false, "malformed proof of work"
|
||||
}
|
||||
hexKey := key.KeyHex
|
||||
s.mu.Lock()
|
||||
rec, ok := s.powChallenges[hexKey]
|
||||
if ok {
|
||||
delete(s.powChallenges, hexKey) // single use, even on failure paths below
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok || rec.purpose != purpose || now().After(rec.expiry) {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
return false, "unknown or expired challenge"
|
||||
}
|
||||
if !pow.Verify(rec.key, target, rec.bits, proof.Counter) {
|
||||
s.metrics.inc(&s.metrics.powFailed)
|
||||
return false, "invalid proof of work"
|
||||
}
|
||||
s.metrics.inc(&s.metrics.powOK)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// zeroTarget is the authentication binding target: an auth challenge proves
|
||||
// work against nothing but the server-chosen key itself.
|
||||
func zeroTarget() [pow.TargetSize]byte {
|
||||
return [pow.TargetSize]byte{}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/server"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
)
|
||||
|
||||
// newPowServer builds a relay with both PoW tiers at a difficulty cheap
|
||||
// enough to solve inline in tests.
|
||||
func newPowServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := server.New(server.Config{Audience: "trust.n1ko.dev"}, server.WithPow(8, 8))
|
||||
return httptest.NewServer(srv.Handler())
|
||||
}
|
||||
|
||||
func getPoWChallenge(t *testing.T, baseURL, purpose string) (keyHex string, difficulty int) {
|
||||
t.Helper()
|
||||
body := ""
|
||||
if purpose != "" {
|
||||
body = `{"purpose":"` + purpose + `"}`
|
||||
}
|
||||
resp, err := http.Post(baseURL+"/v1/pow/challenge", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("challenge status %d: %s", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Key, out.Difficulty
|
||||
}
|
||||
|
||||
func putWithPoW(t *testing.T, baseURL string, tceBytes, sig []byte, keyHex string, counter uint32) *http.Response {
|
||||
t.Helper()
|
||||
body := map[string]any{"tce": tceBytes, "signature": sig}
|
||||
if keyHex != "" {
|
||||
body["pow"] = map[string]any{"key": keyHex, "counter": counter}
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Post(baseURL+"/v1/objects", "application/json", buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func makeClaim(t *testing.T) (tceBytes, sig []byte) {
|
||||
t.Helper()
|
||||
issuer, _ := signer.Generate()
|
||||
subject, _ := signer.Generate()
|
||||
c := &protocol.Claim{
|
||||
Issuer: issuer.Public(),
|
||||
Subject: subject.Public(),
|
||||
Claims: map[string]tce.Value{"pow.test": tce.Bool(true)},
|
||||
CreatedAt: uint64(time.Now().Unix()),
|
||||
Serial: 1,
|
||||
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
|
||||
}
|
||||
b, err := protocol.EncodeClaim(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, issuer.Sign(b)
|
||||
}
|
||||
|
||||
func TestPutRequiresProofOfWork(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
b, sig := makeClaim(t)
|
||||
resp := putWithPoW(t, ts.URL, b, sig, "", 0)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if !strings.Contains(out.Error, "proof of work required") {
|
||||
t.Fatalf("unexpected error %q", out.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutWithValidProofSucceedsAndChallengeIsSingleUse(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
|
||||
if diff != 8 {
|
||||
t.Fatalf("difficulty %d, want 8", diff)
|
||||
}
|
||||
|
||||
b, sig := makeClaim(t)
|
||||
target := [32]byte(tce.ComputeID(b))
|
||||
k, err := pow.ParseKey(keyHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter, ok := pow.Solve(k, target, diff)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
|
||||
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, raw)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// The same challenge must not open a second submission.
|
||||
b2, sig2 := makeClaim(t)
|
||||
resp2 := putWithPoW(t, ts.URL, b2, sig2, keyHex, counter)
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("replayed challenge status %d, want 429", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutWithWrongCounterFails(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
b, sig := makeClaim(t)
|
||||
target := [32]byte(tce.ComputeID(b))
|
||||
|
||||
// At low difficulty the successor of the minimal solution occasionally
|
||||
// also meets the target (~2^-8); retry challenges until the wrong
|
||||
// counter is provably wrong, keeping the test deterministic.
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
|
||||
k, err := pow.ParseKey(keyHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counter, ok := pow.Solve(k, target, diff)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
if pow.Verify(k, target, diff, counter+1) {
|
||||
continue // unlucky key: successor is also valid, try another
|
||||
}
|
||||
|
||||
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter+1)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429", resp.StatusCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Skip("no challenge yielded an invalid successor within budget")
|
||||
}
|
||||
|
||||
func TestChallengePurposeIsEnforced(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// An auth-purpose challenge must not unlock object storage.
|
||||
keyHex, _ := getPoWChallenge(t, ts.URL, "auth")
|
||||
b, sig := makeClaim(t)
|
||||
resp := putWithPoW(t, ts.URL, b, sig, keyHex, 7)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("cross-purpose challenge accepted with status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthChallengeRequiresProofOfWork(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Without a proof the auth challenge is refused.
|
||||
resp, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429", resp.StatusCode)
|
||||
}
|
||||
|
||||
// With a valid proof it succeeds and returns an auth challenge.
|
||||
keyHex, diff := getPoWChallenge(t, ts.URL, "auth")
|
||||
k, _ := pow.ParseKey(keyHex)
|
||||
var zero [32]byte
|
||||
counter, ok := pow.Solve(k, zero, diff)
|
||||
if !ok {
|
||||
t.Fatal("solve failed")
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
json.NewEncoder(buf).Encode(map[string]any{"pow": map[string]any{"key": keyHex, "counter": counter}})
|
||||
resp2, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf("status %d: %s", resp2.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&out); err != nil || out.Challenge == "" {
|
||||
t.Fatalf("no challenge returned (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownChallengeRejected(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
fake := pow.NewKey()
|
||||
b, sig := makeClaim(t)
|
||||
resp := putWithPoW(t, ts.URL, b, sig, fake.String(), 3)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoWChallengeRateLimitedPerIP(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, err := http.Post(ts.URL+"/v1/pow/challenge", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("request %d: status %d, want 200", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
resp, err := http.Post(ts.URL+"/v1/pow/challenge", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status %d, want 429 on request 31", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoWMetricsExposed(t *testing.T) {
|
||||
ts := newPowServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
keyHex, diff := getPoWChallenge(t, ts.URL, "put")
|
||||
k, _ := pow.ParseKey(keyHex)
|
||||
b, sig := makeClaim(t)
|
||||
counter, _ := pow.Solve(k, [32]byte(tce.ComputeID(b)), diff)
|
||||
resp := putWithPoW(t, ts.URL, b, sig, keyHex, counter)
|
||||
resp.Body.Close()
|
||||
|
||||
mresp, err := http.Get(ts.URL + "/v1/metrics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mresp.Body.Close()
|
||||
raw, _ := io.ReadAll(mresp.Body)
|
||||
for _, want := range []string{"trust_pow_ok 1", "trust_pow_failed"} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("metrics missing %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
fmt.Fprint(io.Discard, raw)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
|
@ -14,7 +13,6 @@ import (
|
|||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/identity"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
||||
|
|
@ -33,31 +31,15 @@ type Server struct {
|
|||
challengeTTL time.Duration
|
||||
sessionTTL time.Duration
|
||||
|
||||
putLimiter *ipLimiter
|
||||
challengeLimiter *ipLimiter
|
||||
powChallengeLimiter *ipLimiter
|
||||
putLimiter *ipLimiter
|
||||
challengeLimiter *ipLimiter
|
||||
|
||||
powPutBits int
|
||||
powAuthBits int
|
||||
|
||||
mu sync.Mutex
|
||||
challenges map[string]time.Time // challenge hex -> expiry
|
||||
powChallenges map[string]powChallengeRecord // pow key hex -> issued challenge
|
||||
sessions map[string]session // session token -> session
|
||||
|
||||
// ckpt publishes the signed object-set commitment; nil when disabled.
|
||||
ckpt *checkpointState
|
||||
|
||||
// gossip records heads other relays have announced, for split-view
|
||||
// detection. Present even when checkpoints are disabled locally: a node
|
||||
// can relay others' claims about their own logs.
|
||||
gossip *gossipState
|
||||
mu sync.Mutex
|
||||
challenges map[string]time.Time // challenge hex -> expiry
|
||||
sessions map[string]session // session token -> session
|
||||
|
||||
// ws streams newly stored envelopes to subscribed sessions.
|
||||
ws *wsHubState
|
||||
|
||||
// bft is the optional finality validator state; nil unless enabled.
|
||||
bft *bftState
|
||||
}
|
||||
|
||||
type session struct {
|
||||
|
|
@ -67,9 +49,8 @@ type session struct {
|
|||
}
|
||||
|
||||
// New builds a relay from the given Config. Any zero-valued field falls back to
|
||||
// DefaultConfig, so callers may pass a partially-populated Config. Network-layer
|
||||
// features (PoW, checkpoints, BFT) are layered on via Options.
|
||||
func New(cfg Config, opts ...Option) *Server {
|
||||
// DefaultConfig, so callers may pass a partially-populated Config.
|
||||
func New(cfg Config) *Server {
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = ":8080"
|
||||
}
|
||||
|
|
@ -104,23 +85,17 @@ func New(cfg Config, opts ...Option) *Server {
|
|||
cfg.SessionTTL = 30 * time.Minute
|
||||
}
|
||||
s := &Server{
|
||||
store: NewStore(cfg.DataDir, cfg.MaxPerSubject),
|
||||
audience: cfg.Audience,
|
||||
metrics: NewMetrics(),
|
||||
maxBodyBytes: cfg.MaxBodyBytes,
|
||||
challengeTTL: cfg.ChallengeTTL,
|
||||
sessionTTL: cfg.SessionTTL,
|
||||
challenges: make(map[string]time.Time),
|
||||
powChallenges: make(map[string]powChallengeRecord),
|
||||
sessions: make(map[string]session),
|
||||
gossip: newGossipState(),
|
||||
ws: newWSHub(),
|
||||
putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow),
|
||||
challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow),
|
||||
powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
store: NewStore(cfg.DataDir, cfg.MaxPerSubject),
|
||||
audience: cfg.Audience,
|
||||
metrics: NewMetrics(),
|
||||
maxBodyBytes: cfg.MaxBodyBytes,
|
||||
challengeTTL: cfg.ChallengeTTL,
|
||||
sessionTTL: cfg.SessionTTL,
|
||||
challenges: make(map[string]time.Time),
|
||||
sessions: make(map[string]session),
|
||||
ws: newWSHub(),
|
||||
putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow),
|
||||
challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow),
|
||||
}
|
||||
s.ready = true
|
||||
return s
|
||||
|
|
@ -145,18 +120,7 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.HandleFunc("GET /v1/readyz", s.handleReady)
|
||||
mux.HandleFunc("POST /v1/auth/challenge", s.rateLimitChallenge)
|
||||
mux.HandleFunc("POST /v1/auth/assert", s.handleAssert)
|
||||
mux.HandleFunc("POST /v1/pow/challenge", s.rateLimitPowChallenge)
|
||||
mux.HandleFunc("GET /v1/checkpoint/latest", s.handleCheckpointLatest)
|
||||
mux.HandleFunc("GET /v1/checkpoint/{epoch}", s.handleCheckpointEpoch)
|
||||
mux.HandleFunc("GET /v1/proof/object/{id}", s.handleProofObject)
|
||||
mux.HandleFunc("GET /v1/proof/absent/{id}", s.handleProofAbsent)
|
||||
mux.HandleFunc("POST /v1/gossip/checkpoint", s.handleGossipCheckpoint)
|
||||
mux.HandleFunc("GET /v1/peers/heads", s.handlePeerHeads)
|
||||
mux.HandleFunc("GET /v1/ws", s.handleWS)
|
||||
mux.HandleFunc("POST /v1/bft/proposal", s.handleBFTProposal)
|
||||
mux.HandleFunc("POST /v1/bft/vote", s.handleBFTVote)
|
||||
mux.HandleFunc("GET /v1/bft/state", s.handleBFTState)
|
||||
mux.HandleFunc("GET /v1/bft/certificate/{height}", s.handleBFTCertificate)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -206,15 +170,6 @@ func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
|
|||
writeErr(w, http.StatusBadRequest, "missing tce")
|
||||
return
|
||||
}
|
||||
// Admission control binds the proof to this exact submission by hashing
|
||||
// over the content ID of the enclosed bytes.
|
||||
id := tce.ComputeID(req.TCE)
|
||||
var target [pow.TargetSize]byte
|
||||
copy(target[:], id.Bytes())
|
||||
if ok, msg := s.admitPoW(req.Pow, powPurposePut, target, s.powPutBits); !ok {
|
||||
writeErr(w, http.StatusTooManyRequests, msg)
|
||||
return
|
||||
}
|
||||
objectID, created, err := s.store.Put(req.TCE, req.Signature, req.ObjectID)
|
||||
if err != nil {
|
||||
s.metrics.inc(&s.metrics.objectsRejected)
|
||||
|
|
@ -225,10 +180,7 @@ func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
|
|||
if created {
|
||||
if typ, obj, derr := transport.DecodeObject(req.TCE); derr == nil {
|
||||
s.metrics.incStoredType(typ)
|
||||
if created {
|
||||
s.notifyCheckpoint()
|
||||
s.hubBroadcastObj(typ, obj, req.TCE, req.Signature, objectID)
|
||||
}
|
||||
s.hubBroadcastObj(typ, obj, req.TCE, req.Signature, objectID)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"object_id": objectID})
|
||||
|
|
@ -425,29 +377,9 @@ func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]string{"audience": s.audience}
|
||||
if s.ckpt != nil {
|
||||
out["relay_pubkey"] = s.ckpt.pubHex
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"audience": s.audience})
|
||||
}
|
||||
|
||||
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
// Admission control for challenge issuance itself: an anonymous flood of
|
||||
// auth attempts must pay per attempt, not merely per IP per minute.
|
||||
// The body is optional when no PoW tier is configured.
|
||||
var body wireRequest
|
||||
raw := s.readCapped(w, r)
|
||||
if len(bytes.TrimSpace(raw)) > 0 {
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad envelope")
|
||||
return
|
||||
}
|
||||
}
|
||||
if ok, msg := s.admitPoW(body.Pow, powPurposeAuth, zeroTarget(), s.powAuthBits); !ok {
|
||||
writeErr(w, http.StatusTooManyRequests, msg)
|
||||
return
|
||||
}
|
||||
ch := make([]byte, tce.ChallengeSize)
|
||||
if _, err := rand.Read(ch); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "challenge")
|
||||
|
|
@ -565,10 +497,9 @@ func identityFromPubKey(pub []byte) string {
|
|||
// object storage and authentication. The optional pow field carries a solved
|
||||
// admission challenge; it is transport metadata and never reaches TCE bytes.
|
||||
type wireRequest struct {
|
||||
TCE []byte `json:"tce"`
|
||||
Signature []byte `json:"signature"`
|
||||
ObjectID string `json:"object_id,omitempty"`
|
||||
Pow *wirePow `json:"pow,omitempty"`
|
||||
TCE []byte `json:"tce"`
|
||||
Signature []byte `json:"signature"`
|
||||
ObjectID string `json:"object_id,omitempty"`
|
||||
}
|
||||
|
||||
// decodeWire reads a JSON request body from the request, enforcing the
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"time"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
||||
)
|
||||
|
|
@ -57,11 +56,6 @@ type Store struct {
|
|||
// letting anyone pick a winner (docs/ROTATION.md).
|
||||
confirmedRotations map[string]struct{}
|
||||
|
||||
// trie commits to the set of stored object IDs. It is grow-only and its
|
||||
// root is a function of the set alone, so restarts and insertion order
|
||||
// cannot change it (docs/CHECKPOINT.md).
|
||||
trie *smt.Trie
|
||||
|
||||
// maxPerSubject bounds how many claims a single subject may have in the
|
||||
// store, to keep the in-memory indexes from growing without bound under a
|
||||
// hostile or buggy publisher.
|
||||
|
|
@ -83,7 +77,6 @@ func NewStore(dir string, maxPerSubject int) *Store {
|
|||
|
||||
answeredRequests: make(map[string]struct{}),
|
||||
confirmedRotations: make(map[string]struct{}),
|
||||
trie: smt.New(),
|
||||
maxPerSubject: maxPerSubject,
|
||||
}
|
||||
if dir != "" {
|
||||
|
|
@ -144,7 +137,6 @@ func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, bool, erro
|
|||
// addLocked inserts an already-validated object into the in-memory indexes. The
|
||||
// caller must hold s.mu.
|
||||
func (s *Store) addLocked(tceBytes, sig []byte, id string, obj any) {
|
||||
s.trieInsertLocked(id)
|
||||
s.byID[id] = &transport.Envelope{
|
||||
TCE: append([]byte(nil), tceBytes...),
|
||||
Signature: append([]byte(nil), sig...),
|
||||
|
|
@ -340,50 +332,7 @@ func addKey(m map[string]map[string]struct{}, key, id string) {
|
|||
set[id] = struct{}{}
|
||||
}
|
||||
|
||||
// TrieRoot returns the Merkle commitment over the stored object ID set.
|
||||
func (s *Store) TrieRoot() [32]byte {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.trie.Root()
|
||||
}
|
||||
|
||||
// TrieSize returns the number of distinct objects committed by TrieRoot.
|
||||
func (s *Store) TrieSize() uint64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return uint64(s.trie.Len())
|
||||
}
|
||||
|
||||
// ProofFor returns the trie root and an inclusion proof (wantPresent) or
|
||||
// absence proof for the given object ID. Requesting the wrong kind for what
|
||||
// the store actually holds is a caller error, reported via sentinel errors.
|
||||
func (s *Store) ProofFor(id [32]byte, wantPresent bool) ([32]byte, []byte, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
root := s.trie.Root()
|
||||
if wantPresent {
|
||||
proof, err := s.trie.InclusionProof(id)
|
||||
if err != nil {
|
||||
return root, nil, errProofAbsentRequested
|
||||
}
|
||||
return root, proof, nil
|
||||
}
|
||||
proof, err := s.trie.AbsenceProof(id)
|
||||
if err != nil {
|
||||
return root, nil, errProofPresentRequested
|
||||
}
|
||||
return root, proof, nil
|
||||
}
|
||||
|
||||
// trieInsertLocked adds one object ID to the commitment. The caller holds
|
||||
// s.mu for writing and has already established the ID is new.
|
||||
func (s *Store) trieInsertLocked(id string) {
|
||||
k, err := tce.ParseID(id)
|
||||
if err != nil {
|
||||
return // unreachable: ids are always computed content addresses
|
||||
}
|
||||
s.trie.Insert([32]byte(k))
|
||||
}
|
||||
|
||||
// now is overridable in tests.
|
||||
var now = time.Now
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
package smt_test
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
)
|
||||
|
||||
// FuzzVerifyProofsIsTotal asserts the proof verifiers are total functions of
|
||||
// their inputs: any byte string either verifies or does not, and neither
|
||||
// outcome may panic. Valid proofs for random sets are seeded so the fuzzer
|
||||
// starts from well-formed structures and mutates around them.
|
||||
func FuzzVerifyProofsIsTotal(f *testing.F) {
|
||||
r := rand.New(rand.NewSource(3))
|
||||
keys := make([][32]byte, 30)
|
||||
for i := range keys {
|
||||
r.Read(keys[i][:])
|
||||
}
|
||||
tr := smt.New()
|
||||
for _, k := range keys {
|
||||
tr.Insert(k)
|
||||
}
|
||||
root := tr.Root()
|
||||
|
||||
var incSeed []byte
|
||||
if p, err := tr.InclusionProof(keys[0]); err == nil {
|
||||
incSeed = p
|
||||
f.Add(append([]byte(nil), p...))
|
||||
}
|
||||
if p, err := tr.AbsenceProof(keys[0]); err == nil {
|
||||
f.Add(append([]byte(nil), p...))
|
||||
}
|
||||
var absent [32]byte
|
||||
absent = keys[1]
|
||||
absent[0] ^= 0x80
|
||||
if !tr.Contains(absent) {
|
||||
if p, err := tr.AbsenceProof(absent); err == nil {
|
||||
f.Add(append([]byte(nil), p...))
|
||||
}
|
||||
}
|
||||
f.Add([]byte{0x01, 0x00})
|
||||
f.Add([]byte{0x02})
|
||||
f.Add([]byte{0x03, 0xff})
|
||||
f.Add([]byte{})
|
||||
|
||||
var probe [32]byte
|
||||
probe = keys[2]
|
||||
|
||||
f.Fuzz(func(t *testing.T, proof []byte) {
|
||||
smt.VerifyInclusion(root, probe, proof)
|
||||
smt.VerifyAbsence(root, probe, proof)
|
||||
smt.VerifyInclusion(smt.EmptyRoot, probe, proof)
|
||||
_ = incSeed
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzInsertNeverCorrupts drives Insert with arbitrary keys and asserts the
|
||||
// core invariants after every mutation: membership accuracy and root
|
||||
// stability under replay.
|
||||
func FuzzInsertNeverCorrupts(f *testing.F) {
|
||||
f.Add([]byte{1}, []byte{2})
|
||||
f.Add(make([]byte, 32), make([]byte, 32))
|
||||
|
||||
f.Fuzz(func(t *testing.T, a, b []byte) {
|
||||
var ka, kb [32]byte
|
||||
copy(ka[:], a)
|
||||
copy(kb[:], b)
|
||||
|
||||
t1 := smt.New()
|
||||
t1.Insert(ka)
|
||||
t1.Insert(kb)
|
||||
root1 := t1.Root()
|
||||
|
||||
t2 := smt.New()
|
||||
t2.Insert(kb)
|
||||
t2.Insert(ka)
|
||||
if t1.Len() == t2.Len() && root1 != t2.Root() {
|
||||
t.Fatalf("order dependence: %x vs %x", root1, t2.Root())
|
||||
}
|
||||
if !t1.Contains(ka) || !t1.Contains(kb) {
|
||||
t.Fatal("inserted key lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
package smt
|
||||
|
||||
// Proof encodings. All multi-byte integers are uvarints; bit strings are
|
||||
// packed MSB-first; hashes are 32 raw bytes.
|
||||
//
|
||||
// 0x01 || nSteps || step… inclusion
|
||||
// 0x02 || nSteps || step… || witnessKey absence, leaf witness
|
||||
// 0x03 || nSteps || step… || m || prefix || left || right absence, branch witness
|
||||
// 0x04 absence in an empty trie
|
||||
//
|
||||
// step := nPrefixBits || prefixBits || siblingSide(1 byte) || siblingHash
|
||||
//
|
||||
// A step describes one branch on the path from the root toward the subject
|
||||
// key: prefix is that branch's matched bits (committed inside its hash),
|
||||
// siblingSide is the bit selecting the sibling subtree at the branch's
|
||||
// split, and siblingHash is that subtree's commitment.
|
||||
//
|
||||
// Verification recomposes the root from the subject side upward. Because
|
||||
// every step's prefix must also match the subject key's own bits at the
|
||||
// tracked depth, a proof constructed for one key verifies for no other.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type proofStep struct {
|
||||
prefix []byte
|
||||
prefixLen int
|
||||
sibSide byte
|
||||
sibling [32]byte
|
||||
}
|
||||
|
||||
const (
|
||||
proofInclusion = 0x01
|
||||
proofAbsentLeaf = 0x02
|
||||
proofAbsentBranch = 0x03
|
||||
proofAbsentEmpty = 0x04
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- generation
|
||||
|
||||
// walkDescend follows key's bits through the trie. It returns the steps
|
||||
// crossed (top-down) and where the walk stopped: at a leaf, or diverging
|
||||
// inside a branch's prefix.
|
||||
func (t *Trie) walkDescend(key *[KeySize]byte) (steps []proofStep, leaf *node, branch *node, depth int) {
|
||||
n := t.root
|
||||
d := 0
|
||||
for n != nil && !n.leaf {
|
||||
i := 0
|
||||
for i < n.prefixLen && bit(key, d+i) == prefixBit(n.prefix, i) {
|
||||
i++
|
||||
}
|
||||
if i < n.prefixLen {
|
||||
return steps, nil, n, d
|
||||
}
|
||||
sd := d + n.prefixLen
|
||||
b := bit(key, sd)
|
||||
child := n.left
|
||||
sib := n.right
|
||||
sibSide := byte(1)
|
||||
if b == 1 {
|
||||
child = n.right
|
||||
sib = n.left
|
||||
sibSide = 0
|
||||
}
|
||||
steps = append(steps, proofStep{
|
||||
prefix: append([]byte(nil), n.prefix...),
|
||||
prefixLen: n.prefixLen,
|
||||
sibSide: sibSide,
|
||||
sibling: t.hash(sib),
|
||||
})
|
||||
n = child
|
||||
d = sd + 1
|
||||
}
|
||||
if n != nil {
|
||||
return steps, n, nil, d
|
||||
}
|
||||
return steps, nil, nil, d // empty trie
|
||||
}
|
||||
|
||||
// InclusionProof returns a proof that key is in the trie.
|
||||
func (t *Trie) InclusionProof(key [KeySize]byte) ([]byte, error) {
|
||||
steps, leaf, _, _ := t.walkDescend(&key)
|
||||
if t.root == nil || leaf == nil || leaf.key != key {
|
||||
return nil, ErrAbsent
|
||||
}
|
||||
out := []byte{proofInclusion}
|
||||
out = appendUvarint(out, len(steps))
|
||||
out = appendSteps(out, steps)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AbsenceProof returns a proof that key is not in the trie.
|
||||
func (t *Trie) AbsenceProof(key [KeySize]byte) ([]byte, error) {
|
||||
if t.root == nil {
|
||||
return []byte{proofAbsentEmpty}, nil
|
||||
}
|
||||
steps, leaf, branch, _ := t.walkDescend(&key)
|
||||
switch {
|
||||
case leaf != nil && leaf.key == key:
|
||||
return nil, ErrPresent
|
||||
|
||||
case leaf != nil:
|
||||
out := []byte{proofAbsentLeaf}
|
||||
out = appendUvarint(out, len(steps))
|
||||
out = appendSteps(out, steps)
|
||||
out = append(out, leaf.key[:]...)
|
||||
return out, nil
|
||||
|
||||
default:
|
||||
// Diverged inside branch.prefix at some bit; the branch itself is
|
||||
// the witness and stays intact in the tree.
|
||||
out := []byte{proofAbsentBranch}
|
||||
out = appendUvarint(out, len(steps))
|
||||
out = appendSteps(out, steps)
|
||||
out = appendUvarint(out, branch.prefixLen)
|
||||
out = append(out, branch.prefix...)
|
||||
l := t.hash(branch.left)
|
||||
r := t.hash(branch.right)
|
||||
out = append(out, l[:]...)
|
||||
out = append(out, r[:]...)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func appendSteps(out []byte, steps []proofStep) []byte {
|
||||
for _, st := range steps {
|
||||
out = appendUvarint(out, st.prefixLen)
|
||||
out = append(out, st.prefix...)
|
||||
out = append(out, st.sibSide)
|
||||
out = append(out, st.sibling[:]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- verifying
|
||||
|
||||
type parsedStep struct {
|
||||
prefix []byte
|
||||
prefixLen int
|
||||
sibSide byte
|
||||
sibling [32]byte
|
||||
split int // absolute bit index of the branch's split decision
|
||||
}
|
||||
|
||||
type proofParser struct {
|
||||
b []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *proofParser) byteVal() (byte, error) {
|
||||
if p.pos >= len(p.b) {
|
||||
return 0, ErrBadProof
|
||||
}
|
||||
v := p.b[p.pos]
|
||||
p.pos++
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (p *proofParser) uvarint() (int, error) {
|
||||
v, n := binary.Uvarint(p.b[p.pos:])
|
||||
// Every uvarint in a proof is either a step count or a bit length; both
|
||||
// are bounded by one key's worth of bits.
|
||||
if n <= 0 || v > KeySize*8 {
|
||||
return 0, ErrBadProof
|
||||
}
|
||||
p.pos += n
|
||||
return int(v), nil
|
||||
}
|
||||
|
||||
func (p *proofParser) bytes(n int) ([]byte, error) {
|
||||
if n < 0 || p.pos+n > len(p.b) {
|
||||
return nil, ErrBadProof
|
||||
}
|
||||
out := p.b[p.pos : p.pos+n]
|
||||
p.pos += n
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var errTrailing = errors.New("smt: trailing bytes after proof")
|
||||
|
||||
func (p *proofParser) done() error {
|
||||
if p.pos != len(p.b) {
|
||||
return errTrailing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSteps reads n steps, validating each prefix against key at the
|
||||
// running depth, and returns them with their split positions.
|
||||
func parseSteps(p *proofParser, n int, key *[KeySize]byte) ([]parsedStep, error) {
|
||||
d := 0
|
||||
steps := make([]parsedStep, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
plen, err := p.uvarint()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nb int
|
||||
if plen > 0 {
|
||||
nb = (plen + 7) / 8
|
||||
}
|
||||
pref, err := p.bytes(nb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
side, err := p.byteVal()
|
||||
if err != nil || side > 1 {
|
||||
return nil, ErrBadProof
|
||||
}
|
||||
var sib [32]byte
|
||||
sb, err := p.bytes(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(sib[:], sb)
|
||||
|
||||
// The prefix must be exactly the key's bits here: the walk that
|
||||
// produced this step followed the key, so any other claim is a
|
||||
// malformed proof even before hashes are checked.
|
||||
for j := 0; j < plen; j++ {
|
||||
if prefixBit(pref, j) != bit(key, d+j) {
|
||||
return nil, ErrBadProof
|
||||
}
|
||||
}
|
||||
steps = append(steps, parsedStep{
|
||||
prefix: append([]byte(nil), pref...),
|
||||
prefixLen: plen,
|
||||
sibSide: side,
|
||||
sibling: sib,
|
||||
split: d + plen,
|
||||
})
|
||||
d = d + plen + 1
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// combine folds the subject hash upward through the steps, deepest first.
|
||||
func combine(h *[32]byte, steps []parsedStep) {
|
||||
for i := len(steps) - 1; i >= 0; i-- {
|
||||
st := steps[i]
|
||||
b := byte(1) - st.sibSide // the side the subject subtree occupies
|
||||
var l, r [32]byte
|
||||
if b == 0 {
|
||||
l, r = *h, st.sibling
|
||||
} else {
|
||||
l, r = st.sibling, *h
|
||||
}
|
||||
*h = branchHash(st.prefix, st.prefixLen, &l, &r)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyInclusion reports whether proof attests key's membership in the set
|
||||
// committed to by root.
|
||||
func VerifyInclusion(root [32]byte, key [KeySize]byte, proof []byte) bool {
|
||||
p := &proofParser{b: proof}
|
||||
typ, err := p.byteVal()
|
||||
if err != nil || typ != proofInclusion {
|
||||
return false
|
||||
}
|
||||
n, err := p.uvarint()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
steps, err := parseSteps(p, n, &key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := p.done(); err != nil {
|
||||
return false
|
||||
}
|
||||
h := leafHash(&key)
|
||||
combine(&h, steps)
|
||||
return h == root
|
||||
}
|
||||
|
||||
// VerifyAbsence reports whether proof attests key's absence from the set
|
||||
// committed to by root.
|
||||
func VerifyAbsence(root [32]byte, key [KeySize]byte, proof []byte) bool {
|
||||
p := &proofParser{b: proof}
|
||||
typ, err := p.byteVal()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case proofAbsentEmpty:
|
||||
return root == EmptyRoot
|
||||
|
||||
case proofAbsentLeaf:
|
||||
n, err := p.uvarint()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
steps, err := parseSteps(p, n, &key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
wk, err := p.bytes(KeySize)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := p.done(); err != nil {
|
||||
return false
|
||||
}
|
||||
var witness [KeySize]byte
|
||||
copy(witness[:], wk)
|
||||
if witness == key {
|
||||
return false // that would be proof of presence
|
||||
}
|
||||
h := leafHash(&witness)
|
||||
combine(&h, steps)
|
||||
return h == root
|
||||
|
||||
case proofAbsentBranch:
|
||||
n, err := p.uvarint()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
steps, err := parseSteps(p, n, &key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m, err := p.uvarint()
|
||||
if err != nil || m == 0 || m > KeySize*8 {
|
||||
return false
|
||||
}
|
||||
pref, err := p.bytes((m + 7) / 8)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// The key must leave this prefix mid-way: matching fully would mean
|
||||
// the walk continued deeper, differing nowhere means presence of
|
||||
// nothing in particular.
|
||||
j := -1
|
||||
db := 0 // absolute bit index where the witness branch's prefix starts
|
||||
if len(steps) > 0 {
|
||||
db = steps[len(steps)-1].split + 1
|
||||
}
|
||||
for i := 0; i < m; i++ {
|
||||
if prefixBit(pref, i) != bit(&key, db+i) {
|
||||
j = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
var ll, lr [32]byte
|
||||
lb, err := p.bytes(32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
copy(ll[:], lb)
|
||||
rb, err := p.bytes(32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
copy(lr[:], rb)
|
||||
if err := p.done(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hb := branchHash(pref, m, &ll, &lr)
|
||||
// The branch hangs off the last step's split; fold it in place of a
|
||||
// leaf hash, then continue through remaining ancestor steps.
|
||||
if len(steps) == 0 {
|
||||
return hb == root
|
||||
}
|
||||
top := steps[len(steps)-1]
|
||||
b := byte(1) - top.sibSide
|
||||
var l, r [32]byte
|
||||
if b == 0 {
|
||||
l, r = hb, top.sibling
|
||||
} else {
|
||||
l, r = top.sibling, hb
|
||||
}
|
||||
h := branchHash(top.prefix, top.prefixLen, &l, &r)
|
||||
rest := steps[:len(steps)-1]
|
||||
combine(&h, rest)
|
||||
return h == root
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
package smt_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
||||
)
|
||||
|
||||
func keyFromSeed(r *rand.Rand) [32]byte {
|
||||
var k [32]byte
|
||||
r.Read(k[:])
|
||||
return k
|
||||
}
|
||||
|
||||
func buildTrie(keys [][32]byte) (*smt.Trie, [32]byte) {
|
||||
t := smt.New()
|
||||
for _, k := range keys {
|
||||
t.Insert(k)
|
||||
}
|
||||
return t, t.Root()
|
||||
}
|
||||
|
||||
// The root must be a function of the set alone: three tries fed the same
|
||||
// keys in different orders commit to the identical hash.
|
||||
func TestRootIsOrderIndependent(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(42))
|
||||
base := make([][32]byte, 300)
|
||||
for i := range base {
|
||||
base[i] = keyFromSeed(r)
|
||||
}
|
||||
|
||||
shuffled := func(src [][32]byte, seed int64) [][32]byte {
|
||||
out := append([][32]byte(nil), src...)
|
||||
rnd := rand.New(rand.NewSource(seed))
|
||||
rnd.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
|
||||
return out
|
||||
}
|
||||
a := smt.New()
|
||||
b := smt.New()
|
||||
c := smt.New()
|
||||
for _, k := range base {
|
||||
a.Insert(k)
|
||||
}
|
||||
for _, k := range shuffled(base, 7) {
|
||||
b.Insert(k)
|
||||
}
|
||||
for _, k := range shuffled(base, 99) {
|
||||
c.Insert(k)
|
||||
}
|
||||
if a.Root() != b.Root() || b.Root() != c.Root() {
|
||||
t.Fatal("roots differ across insertion orders")
|
||||
}
|
||||
if a.Len() != len(base) {
|
||||
t.Fatalf("len %d, want %d", a.Len(), len(base))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertIdempotent(t *testing.T) {
|
||||
t1, root1 := buildTrie([][32]byte{{1}, {2}})
|
||||
t2, _ := buildTrie([][32]byte{{1}, {2}, {1}, {2}})
|
||||
if t1.Len() != 2 || t2.Len() != 2 {
|
||||
t.Fatalf("duplicate inserts counted: %d %d", t1.Len(), t2.Len())
|
||||
}
|
||||
if t2.Root() != root1 {
|
||||
t.Fatal("re-inserting changed the root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(1))
|
||||
var keys [][32]byte
|
||||
seen := map[[32]byte]bool{}
|
||||
for len(keys) < 50 {
|
||||
k := keyFromSeed(r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
tr, _ := buildTrie(keys)
|
||||
for _, k := range keys {
|
||||
if !tr.Contains(k) {
|
||||
t.Fatal("inserted key not contained")
|
||||
}
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
k := keyFromSeed(r)
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
if tr.Contains(k) {
|
||||
t.Fatal("absent key reported contained")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInclusionAndAbsenceRoundTrip(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(5))
|
||||
var keys [][32]byte
|
||||
seen := map[[32]byte]bool{}
|
||||
for len(keys) < 100 {
|
||||
k := keyFromSeed(r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
tr, root := buildTrie(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
p, err := tr.InclusionProof(k)
|
||||
if err != nil {
|
||||
t.Fatalf("inclusion proof: %v", err)
|
||||
}
|
||||
if !smt.VerifyInclusion(root, k, p) {
|
||||
t.Fatal("valid inclusion proof failed verification")
|
||||
}
|
||||
if _, err := tr.AbsenceProof(k); err == nil {
|
||||
t.Fatal("absence proof generated for present key")
|
||||
} else if err != smt.ErrPresent {
|
||||
t.Fatalf("wrong error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for i := 0; checked < 100 && i < 10000; i++ {
|
||||
k := keyFromSeed(r)
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
p, err := tr.AbsenceProof(k)
|
||||
if err != nil {
|
||||
t.Fatalf("absence proof: %v", err)
|
||||
}
|
||||
if !smt.VerifyAbsence(root, k, p) {
|
||||
t.Fatal("valid absence proof failed verification")
|
||||
}
|
||||
if _, err := tr.InclusionProof(k); err == nil {
|
||||
t.Fatal("inclusion proof generated for absent key")
|
||||
} else if err != smt.ErrAbsent {
|
||||
t.Fatalf("wrong error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyAndSingleKeyTries(t *testing.T) {
|
||||
empty := smt.New()
|
||||
if empty.Root() != smt.EmptyRoot {
|
||||
t.Fatal("empty trie root mismatch")
|
||||
}
|
||||
ap, err := empty.AbsenceProof([32]byte{9})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !smt.VerifyAbsence(smt.EmptyRoot, [32]byte{9}, ap) {
|
||||
t.Fatal("empty-trie absence proof failed")
|
||||
}
|
||||
if smt.VerifyAbsence(smt.EmptyRoot, [32]byte{9}, []byte{0x04}) == false && false {
|
||||
t.Fatal("unreachable")
|
||||
}
|
||||
|
||||
one, root := buildTreeOfOne([32]byte{7})
|
||||
ip, err := one.InclusionProof([32]byte{7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !smt.VerifyInclusion(root, [32]byte{7}, ip) {
|
||||
t.Fatal("single-key inclusion failed")
|
||||
}
|
||||
ap2, err := one.AbsenceProof([32]byte{8})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !smt.VerifyAbsence(root, [32]byte{8}, ap2) {
|
||||
t.Fatal("single-key absence failed")
|
||||
}
|
||||
// The absence proof names its witness; presenting the witness itself as
|
||||
// the queried key must fail.
|
||||
if smt.VerifyAbsence(root, [32]byte{7}, ap2) {
|
||||
t.Fatal("absence proof verified for its own witness (a present key)")
|
||||
}
|
||||
if smt.VerifyInclusion(root, [32]byte{8}, ip) {
|
||||
t.Fatal("inclusion proof for another key verified")
|
||||
}
|
||||
|
||||
// Soundness in the strong direction: an absence proof minted before a
|
||||
// key existed must not verify once that key has been inserted.
|
||||
before, err := tr100().AbsenceProof(absentKey())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tr100()
|
||||
r := rand.New(rand.NewSource(77))
|
||||
var x [32]byte
|
||||
for {
|
||||
r.Read(x[:])
|
||||
if !tr.Contains(x) {
|
||||
break
|
||||
}
|
||||
}
|
||||
pre, err := tr.AbsenceProof(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootBefore := tr.Root()
|
||||
tr.Insert(x)
|
||||
if tr.Root() == rootBefore {
|
||||
t.Fatal("insert did not change root")
|
||||
}
|
||||
if !tr.Contains(x) {
|
||||
t.Fatal("insert lost")
|
||||
}
|
||||
if smt.VerifyAbsence(tr.Root(), x, pre) {
|
||||
t.Fatal("stale absence proof verified after the key was inserted")
|
||||
}
|
||||
if !smt.VerifyAbsence(rootBefore, x, pre) {
|
||||
t.Fatal("fresh absence proof failed against its own root")
|
||||
}
|
||||
_ = before
|
||||
}
|
||||
|
||||
// tr100 returns a fresh trie of 100 random keys and registers the canonical
|
||||
// "guaranteed absent" probe used by the soundness checks above.
|
||||
func tr100() *smt.Trie {
|
||||
r := rand.New(rand.NewSource(123))
|
||||
tr := smt.New()
|
||||
for i := 0; i < 100; i++ {
|
||||
var k [32]byte
|
||||
r.Read(k[:])
|
||||
tr.Insert(k)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func absentKey() [32]byte {
|
||||
r := rand.New(rand.NewSource(321))
|
||||
for {
|
||||
var k [32]byte
|
||||
r.Read(k[:])
|
||||
return k
|
||||
}
|
||||
}
|
||||
|
||||
func buildTreeOfOne(k [32]byte) (*smt.Trie, [32]byte) {
|
||||
tr := smt.New()
|
||||
tr.Insert(k)
|
||||
return tr, tr.Root()
|
||||
}
|
||||
|
||||
// Sequential and near-identical keys force deep splits and mid-prefix
|
||||
// divergence, the paths naive implementations get wrong.
|
||||
func TestPathologicalKeySets(t *testing.T) {
|
||||
sets := [][][32]byte{
|
||||
seqKeys(0), // 0x000000...
|
||||
seqKeys(255), // 0xffffff...
|
||||
nearKeys(), // all identical except the last bit
|
||||
}
|
||||
for si, set := range sets {
|
||||
tr := smt.New()
|
||||
for _, k := range set {
|
||||
tr.Insert(k)
|
||||
}
|
||||
root := tr.Root()
|
||||
for _, k := range set {
|
||||
p, err := tr.InclusionProof(k)
|
||||
if err != nil {
|
||||
t.Fatalf("set %d inclusion: %v", si, err)
|
||||
}
|
||||
if !smt.VerifyInclusion(root, k, p) {
|
||||
t.Fatalf("set %d inclusion verify failed", si)
|
||||
}
|
||||
}
|
||||
absent := set[0]
|
||||
absent[31] ^= 0x01
|
||||
if tr.Contains(absent) {
|
||||
continue // collision with an existing member; skip
|
||||
}
|
||||
p, err := tr.AbsenceProof(absent)
|
||||
if err != nil {
|
||||
t.Fatalf("set %d absence: %v", si, err)
|
||||
}
|
||||
if !smt.VerifyAbsence(root, absent, p) {
|
||||
t.Fatalf("set %d absence verify failed", si)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seqKeys(first byte) [][32]byte {
|
||||
out := make([][32]byte, 16)
|
||||
for i := range out {
|
||||
out[i] = [32]byte{}
|
||||
out[i][0] = first
|
||||
out[i][31] = byte(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nearKeys() [][32]byte {
|
||||
out := make([][32]byte, 4)
|
||||
for i := range out {
|
||||
for j := range out[i] {
|
||||
out[i][j] = 0xAA
|
||||
}
|
||||
out[i][31] = byte(i & 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTamperedProofsRejected(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(11))
|
||||
keys := make([][32]byte, 40)
|
||||
for i := range keys {
|
||||
keys[i] = keyFromSeed(r)
|
||||
}
|
||||
tr, root := buildTrie(keys)
|
||||
|
||||
inc, err := tr.InclusionProof(keys[3])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absK := keys[3]
|
||||
absK[0] ^= 0x80
|
||||
for tr.Contains(absK) {
|
||||
absK = keyFromSeed(r)
|
||||
}
|
||||
abs, err := tr.AbsenceProof(absK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < len(inc); i++ {
|
||||
bad := append([]byte(nil), inc...)
|
||||
bad[i] ^= 0x01
|
||||
if smt.VerifyInclusion(root, keys[3], bad) {
|
||||
t.Fatalf("tampered inclusion proof (byte %d) verified", i)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(abs); i++ {
|
||||
bad := append([]byte(nil), abs...)
|
||||
bad[i] ^= 0x01
|
||||
if smt.VerifyAbsence(root, absK, bad) {
|
||||
t.Fatalf("tampered absence proof (byte %d) verified", i)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(inc, inc) {
|
||||
t.Fatal("unreachable")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
// Package smt implements a compressed sparse Merkle trie over fixed-size
|
||||
// keys, committed to by a single root hash.
|
||||
//
|
||||
// Purpose. Two relays that store the same set of objects must compute the
|
||||
// same root regardless of insertion order: the root is a function of the
|
||||
// set, so comparing one 32-byte string detects divergence between mirrors
|
||||
// instantly (docs/CHECKPOINT.md). The trie is grow-only — objects are never
|
||||
// removed from the log even when revoked — which keeps every operation
|
||||
// monotone and the proofs simple.
|
||||
//
|
||||
// Hash domain (SHA-256 throughout):
|
||||
//
|
||||
// leaf = H(0x00 || key)
|
||||
// branch = H(0x01 || uvarint(prefixLen) || prefixBits || left || right)
|
||||
// empty = H(0x02)
|
||||
//
|
||||
// The prefix is inside the branch hash so the trie's shape is committed to:
|
||||
// without it, proofs could not be checked for canonicality. A branch exists
|
||||
// only where both children exist and splits exactly at its keys' first
|
||||
// differing bit, which makes the shape a function of the set alone.
|
||||
//
|
||||
// Proofs. An inclusion proof walks from the key's leaf to the root through
|
||||
// sibling hashes. An absence proof witnesses the exact spot where the key
|
||||
// would have lived: either a neighbouring leaf whose path agrees with the
|
||||
// queried key up to that leaf's depth (variant A), or the branch whose
|
||||
// prefix the queried key leaves mid-way (variant B). Both recombine with
|
||||
// the queried key's own bits, so a proof for one key verifies for no other.
|
||||
package smt
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// KeySize is the exact key size: an object content ID.
|
||||
const KeySize = 32
|
||||
|
||||
// EmptyRoot is the root of a trie containing nothing.
|
||||
var EmptyRoot = sha256.Sum256([]byte{0x02})
|
||||
|
||||
// Errors reported by proof generation and verification.
|
||||
var (
|
||||
ErrPresent = errors.New("smt: key is present")
|
||||
ErrAbsent = errors.New("smt: key is absent")
|
||||
ErrBadProof = errors.New("smt: bad proof")
|
||||
)
|
||||
|
||||
type node struct {
|
||||
leaf bool
|
||||
|
||||
// Leaf fields.
|
||||
key [KeySize]byte
|
||||
|
||||
// Branch fields: prefix holds the bits between the parent's split and
|
||||
// this node's own split, packed MSB-first, prefixLen valid bits. A
|
||||
// branch always has both children.
|
||||
prefix []byte
|
||||
prefixLen int
|
||||
left *node
|
||||
right *node
|
||||
}
|
||||
|
||||
// Trie is a grow-only set commitment. Not safe for concurrent use; callers
|
||||
// serialise access.
|
||||
type Trie struct {
|
||||
root *node
|
||||
n int
|
||||
}
|
||||
|
||||
// New returns an empty trie.
|
||||
func New() *Trie { return &Trie{} }
|
||||
|
||||
// Len returns the number of distinct keys inserted.
|
||||
func (t *Trie) Len() int { return t.n }
|
||||
|
||||
// ---------------------------------------------------------------- primitives
|
||||
|
||||
func bit(k *[KeySize]byte, i int) byte {
|
||||
return k[i>>3] >> (7 - uint(i&7)) & 1
|
||||
}
|
||||
|
||||
func prefixBit(p []byte, i int) byte {
|
||||
return p[i>>3] >> (7 - uint(i&7)) & 1
|
||||
}
|
||||
|
||||
func commonPrefixLen(a, b *[KeySize]byte) int {
|
||||
for i := 0; i < KeySize*8; i++ {
|
||||
if bit(a, i) != bit(b, i) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return KeySize * 8
|
||||
}
|
||||
|
||||
func packBits(k *[KeySize]byte, from, n int) []byte {
|
||||
out := make([]byte, (n+7)/8)
|
||||
for i := 0; i < n; i++ {
|
||||
if bit(k, from+i) == 1 {
|
||||
out[i>>3] |= 0x80 >> uint(i&7)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendUvarint(b []byte, v int) []byte {
|
||||
var tmp [10]byte
|
||||
n := binary.PutUvarint(tmp[:], uint64(v))
|
||||
return append(b, tmp[:n]...)
|
||||
}
|
||||
|
||||
func leafHash(key *[KeySize]byte) [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(key[:])
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func branchHash(prefix []byte, prefixLen int, left, right *[32]byte) [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte{0x01})
|
||||
var tmp [10]byte
|
||||
n := binary.PutUvarint(tmp[:], uint64(prefixLen))
|
||||
h.Write(tmp[:n])
|
||||
h.Write(prefix)
|
||||
h.Write(left[:])
|
||||
h.Write(right[:])
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *Trie) hash(n *node) [32]byte {
|
||||
if n.leaf {
|
||||
return leafHash(&n.key)
|
||||
}
|
||||
l := t.hash(n.left)
|
||||
r := t.hash(n.right)
|
||||
return branchHash(n.prefix, n.prefixLen, &l, &r)
|
||||
}
|
||||
|
||||
// Root returns the set commitment.
|
||||
func (t *Trie) Root() [32]byte {
|
||||
if t.root == nil {
|
||||
return EmptyRoot
|
||||
}
|
||||
return t.hash(t.root)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- insert
|
||||
|
||||
// Insert adds key, reporting whether it was new.
|
||||
func (t *Trie) Insert(key [KeySize]byte) bool {
|
||||
root, added := insertNode(t.root, &key, 0)
|
||||
t.root = root
|
||||
if added {
|
||||
t.n++
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// Contains reports whether key has been inserted.
|
||||
func (t *Trie) Contains(key [KeySize]byte) bool {
|
||||
n := t.root
|
||||
d := 0
|
||||
for n != nil && !n.leaf {
|
||||
i := 0
|
||||
for i < n.prefixLen && bit(&key, d+i) == prefixBit(n.prefix, i) {
|
||||
i++
|
||||
}
|
||||
if i < n.prefixLen {
|
||||
return false // diverged inside the prefix
|
||||
}
|
||||
d += n.prefixLen
|
||||
if bit(&key, d) == 0 {
|
||||
n = n.left
|
||||
} else {
|
||||
n = n.right
|
||||
}
|
||||
d++
|
||||
}
|
||||
return n != nil && n.key == key
|
||||
}
|
||||
|
||||
// insertNode inserts key into the subtree rooted at n, whose split decision
|
||||
// begins at depth d. It returns the possibly-replaced subtree.
|
||||
func insertNode(n *node, key *[KeySize]byte, d int) (*node, bool) {
|
||||
if n == nil {
|
||||
return &node{leaf: true, key: *key}, true
|
||||
}
|
||||
if n.leaf {
|
||||
if n.key == *key {
|
||||
return n, false
|
||||
}
|
||||
k := commonPrefixLen(&n.key, key)
|
||||
// Shared bits d..k-1 become the new branch's prefix; bit k splits.
|
||||
newBranch := &node{
|
||||
prefix: packBits(key, d, k-d),
|
||||
prefixLen: k - d,
|
||||
}
|
||||
if bit(key, k) == 0 {
|
||||
newBranch.left = &node{leaf: true, key: *key}
|
||||
newBranch.right = n
|
||||
} else {
|
||||
newBranch.left = n
|
||||
newBranch.right = &node{leaf: true, key: *key}
|
||||
}
|
||||
return newBranch, true
|
||||
}
|
||||
|
||||
// Branch: follow the prefix while it matches.
|
||||
i := 0
|
||||
for i < n.prefixLen && bit(key, d+i) == prefixBit(n.prefix, i) {
|
||||
i++
|
||||
}
|
||||
if i < n.prefixLen {
|
||||
// The key diverges inside the prefix at absolute bit d+i. Split the
|
||||
// branch there: the upper half becomes the new branch's prefix, the
|
||||
// old branch keeps the remainder as its own prefix.
|
||||
j := d + i
|
||||
upperPrefix := make([]byte, (i+7)/8)
|
||||
for b := 0; b < i; b++ {
|
||||
if prefixBit(n.prefix, b) == 1 {
|
||||
upperPrefix[b>>3] |= 0x80 >> uint(b&7)
|
||||
}
|
||||
}
|
||||
upper := &node{
|
||||
prefix: upperPrefix,
|
||||
prefixLen: i,
|
||||
}
|
||||
tail := copyTailBits(n.prefix, i, n.prefixLen)
|
||||
lower := &node{
|
||||
prefix: tail,
|
||||
prefixLen: n.prefixLen - i - 1,
|
||||
left: n.left,
|
||||
right: n.right,
|
||||
}
|
||||
leaf := &node{leaf: true, key: *key}
|
||||
if bit(key, j) == 0 {
|
||||
upper.left = leaf
|
||||
upper.right = lower
|
||||
} else {
|
||||
upper.left = lower
|
||||
upper.right = leaf
|
||||
}
|
||||
return upper, true
|
||||
}
|
||||
|
||||
sd := d + n.prefixLen
|
||||
var added bool
|
||||
if bit(key, sd) == 0 {
|
||||
n.left, added = insertNode(n.left, key, sd+1)
|
||||
} else {
|
||||
n.right, added = insertNode(n.right, key, sd+1)
|
||||
}
|
||||
return n, added
|
||||
}
|
||||
|
||||
// copyTailBits extracts bits [from+1, end) of p, MSB-packed.
|
||||
func copyTailBits(p []byte, from, end int) []byte {
|
||||
n := end - from - 1
|
||||
out := make([]byte, (n+7)/8)
|
||||
for i := 0; i < n; i++ {
|
||||
if prefixBit(p, from+1+i) == 1 {
|
||||
out[i>>3] |= 0x80 >> uint(i&7)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
go test fuzz v1
|
||||
[]byte("\x03\x00\x0e00")
|
||||
91
testdata/vectors/pow_vectors.json
vendored
91
testdata/vectors/pow_vectors.json
vendored
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"domain": "trust.n1ko.dev/pow/1",
|
||||
"hash_spec": "BLAKE3_keyed(key, domain || target || counter_be); valid iff leading_zero_bits(sum) >= difficulty",
|
||||
"max_difficulty": 30,
|
||||
"vectors": [
|
||||
{
|
||||
"name": "put/key-seed01-target-seed02/diff1",
|
||||
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"difficulty": 1,
|
||||
"counter": 0,
|
||||
"sum_hex": "63369ce6bfc277ea69b9df45011189776b00463ce4fec6d449fff42bf0908113",
|
||||
"leading_zero_bits": 1
|
||||
},
|
||||
{
|
||||
"name": "put/key-seed01-target-seed02/diff4",
|
||||
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"difficulty": 4,
|
||||
"counter": 1,
|
||||
"sum_hex": "046831eba83ef2b46bbcf8a7a14f3831a55b5555d3be29d26f1fd2fd5767d891",
|
||||
"leading_zero_bits": 5
|
||||
},
|
||||
{
|
||||
"name": "put/key-seed01-target-seed02/diff8",
|
||||
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"difficulty": 8,
|
||||
"counter": 277,
|
||||
"sum_hex": "00fa52a4129d6db6729c75e22f8c72250bc268ac89061e1c1418862e62b91a24",
|
||||
"leading_zero_bits": 8
|
||||
},
|
||||
{
|
||||
"name": "auth/zero-target/diff8",
|
||||
"key_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"target_hex": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"difficulty": 8,
|
||||
"counter": 343,
|
||||
"sum_hex": "00f8503f1687fabdf8d0a73d12d5081b748cc87d2d9959154ff3d6cf69a5eb43",
|
||||
"leading_zero_bits": 8
|
||||
},
|
||||
{
|
||||
"name": "put/key-seed02-target-seed03/diff12",
|
||||
"key_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"target_hex": "0303030303030303030303030303030303030303030303030303030303030303",
|
||||
"difficulty": 12,
|
||||
"counter": 11745,
|
||||
"sum_hex": "0009f60682638831f9fafee143bdccdbdf845b32f5658bf5d6d2a8e2341c440f",
|
||||
"leading_zero_bits": 12
|
||||
},
|
||||
{
|
||||
"name": "put/key-seed04-target-seed05/diff16",
|
||||
"key_hex": "0404040404040404040404040404040404040404040404040404040404040404",
|
||||
"target_hex": "0505050505050505050505050505050505050505050505050505050505050505",
|
||||
"difficulty": 16,
|
||||
"counter": 88240,
|
||||
"sum_hex": "000008fed1164db13471bd3bc0721e83c5f643e3bb72c5dfad90c09e89d4c9ef",
|
||||
"leading_zero_bits": 20
|
||||
}
|
||||
],
|
||||
"rejects": [
|
||||
{
|
||||
"name": "counter-misses-threshold",
|
||||
"key_hex": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"difficulty": 8,
|
||||
"counter": 278,
|
||||
"reason": "leading_zero_bits < difficulty"
|
||||
},
|
||||
{
|
||||
"name": "wrong-key",
|
||||
"key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"target_hex": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"difficulty": 8,
|
||||
"counter": 277,
|
||||
"reason": "hash under a different key does not meet the target"
|
||||
}
|
||||
],
|
||||
"config_rejects": [
|
||||
{
|
||||
"name": "difficulty-above-max",
|
||||
"difficulty": 31,
|
||||
"reason": "configuration above MaxDifficulty is clamped/rejected"
|
||||
},
|
||||
{
|
||||
"name": "negative-difficulty",
|
||||
"difficulty": -1,
|
||||
"reason": "negative configuration is treated as disabled, never as a pass-all proof tier"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reference implementation of the relay's proof-of-work admission control.
|
||||
|
||||
Companion to docs/POW.md and the Go package internal/pow, with the same two
|
||||
purposes as tce_reference.py: generate the frozen vectors in
|
||||
testdata/vectors/pow_vectors.json, and be an independent implementation so
|
||||
that agreement means "two readings of the specification coincide".
|
||||
|
||||
Scheme:
|
||||
|
||||
hash = BLAKE3_keyed(key, DOMAIN || target || counter_be)
|
||||
|
||||
valid when `hash` has at least `difficulty` leading zero bits.
|
||||
|
||||
Every message here is at most len(DOMAIN)+32+4 = 57 bytes, so the reference
|
||||
implements exactly the BLAKE3 rule set for single-block inputs: key words as
|
||||
the chaining value, one compression call over one padded 64-byte block, with
|
||||
CHUNK_START | CHUNK_END | KEYED_HASH flags. That is complete for this input
|
||||
class, not an approximation.
|
||||
|
||||
Run: python3 tools/reference/pow_reference.py --emit testdata/vectors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
DOMAIN = b"trust.n1ko.dev/pow/1"
|
||||
KEY_SIZE = 32
|
||||
TARGET_SIZE = 32
|
||||
MAX_DIFFICULTY = 30
|
||||
|
||||
# BLAKE3 flags for a keyed hash whose entire input is one block: the chunk
|
||||
# starts and ends here, the whole input is the root node, and the hash is
|
||||
# keyed.
|
||||
FLAG_CHUNK_START = 1 << 0
|
||||
FLAG_CHUNK_END = 1 << 1
|
||||
FLAG_ROOT = 1 << 3
|
||||
FLAG_KEYED_HASH = 1 << 4
|
||||
SINGLE_BLOCK_FLAGS = FLAG_CHUNK_START | FLAG_CHUNK_END | FLAG_ROOT | FLAG_KEYED_HASH
|
||||
|
||||
IV = (
|
||||
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
||||
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
|
||||
)
|
||||
MSG_PERMUTATION = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8)
|
||||
|
||||
|
||||
def _rotr(x: int, n: int) -> int:
|
||||
return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _g(s: list[int], a: int, b: int, c: int, d: int, mx: int, my: int) -> None:
|
||||
s[a] = (s[a] + s[b] + mx) & 0xFFFFFFFF
|
||||
s[d] = _rotr(s[d] ^ s[a], 16)
|
||||
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
||||
s[b] = _rotr(s[b] ^ s[c], 12)
|
||||
s[a] = (s[a] + s[b] + my) & 0xFFFFFFFF
|
||||
s[d] = _rotr(s[d] ^ s[a], 8)
|
||||
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
||||
s[b] = _rotr(s[b] ^ s[c], 7)
|
||||
|
||||
|
||||
def _compress(cv: tuple[int, ...], block_words: list[int], block_len: int, flags: int) -> list[int]:
|
||||
counter = 0 # first and only chunk
|
||||
state = list(cv) + list(IV[:4]) + [
|
||||
counter & 0xFFFFFFFF,
|
||||
(counter >> 32) & 0xFFFFFFFF,
|
||||
block_len,
|
||||
flags,
|
||||
]
|
||||
m = list(block_words)
|
||||
for r in range(7):
|
||||
if r > 0:
|
||||
m = [m[MSG_PERMUTATION[i]] for i in range(16)]
|
||||
_g(state, 0, 4, 8, 12, m[0], m[1])
|
||||
_g(state, 1, 5, 9, 13, m[2], m[3])
|
||||
_g(state, 2, 6, 10, 14, m[4], m[5])
|
||||
_g(state, 3, 7, 11, 15, m[6], m[7])
|
||||
_g(state, 0, 5, 10, 15, m[8], m[9])
|
||||
_g(state, 1, 6, 11, 12, m[10], m[11])
|
||||
_g(state, 2, 7, 8, 13, m[12], m[13])
|
||||
_g(state, 3, 4, 9, 14, m[14], m[15])
|
||||
# Final transform: the first half becomes the new chaining value, the
|
||||
# second half the block output. For a single-chunk root input the digest
|
||||
# is the new chaining value: root_output_bytes streams all 16 words, and
|
||||
# a 32-byte digest reads exactly the first eight.
|
||||
for i in range(8):
|
||||
state[i] ^= state[i + 8]
|
||||
state[i + 8] ^= cv[i]
|
||||
return state
|
||||
|
||||
|
||||
def pow_hash(key: bytes, target: bytes, counter: int) -> bytes:
|
||||
"""BLAKE3_keyed(key, DOMAIN || target || counter_be), 32-byte digest."""
|
||||
if len(key) != KEY_SIZE:
|
||||
raise ValueError("key must be 32 bytes")
|
||||
if len(target) != TARGET_SIZE:
|
||||
raise ValueError("target must be 32 bytes")
|
||||
msg = DOMAIN + target + struct.pack(">I", counter)
|
||||
if len(msg) > 64:
|
||||
raise ValueError("reference covers single-block inputs only")
|
||||
|
||||
padded = msg + bytes(64 - len(msg))
|
||||
block = list(struct.unpack("<16I", padded))
|
||||
cv = struct.unpack("<8I", key)
|
||||
words = _compress(cv, block, len(msg), SINGLE_BLOCK_FLAGS)
|
||||
return struct.pack("<8I", *words[:8])
|
||||
|
||||
|
||||
def leading_zero_bits(digest: bytes) -> int:
|
||||
n = 0
|
||||
for byte in digest:
|
||||
if byte == 0:
|
||||
n += 8
|
||||
continue
|
||||
mask = 0x80
|
||||
while mask and not byte & mask:
|
||||
n += 1
|
||||
mask >>= 1
|
||||
break
|
||||
return n
|
||||
|
||||
|
||||
def solve(key: bytes, target: bytes, difficulty: int) -> int:
|
||||
counter = 0
|
||||
while True:
|
||||
if leading_zero_bits(pow_hash(key, target, counter)) >= difficulty:
|
||||
return counter
|
||||
counter += 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Vector generation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def fixture(seed: int) -> bytes:
|
||||
return bytes([seed]) * KEY_SIZE
|
||||
|
||||
|
||||
def emit(out_dir: Path) -> None:
|
||||
vectors = []
|
||||
rejects = []
|
||||
|
||||
# (key_seed, target_seed or None for zeros, difficulty)
|
||||
cases = [
|
||||
(1, 2, 1),
|
||||
(1, 2, 4),
|
||||
(1, 2, 8),
|
||||
(2, 0, 8), # zero target: the authentication binding
|
||||
(2, 3, 12),
|
||||
(4, 5, 16),
|
||||
]
|
||||
for key_seed, target_seed, difficulty in cases:
|
||||
key = fixture(key_seed)
|
||||
target = bytes(TARGET_SIZE) if target_seed == 0 else fixture(target_seed)
|
||||
counter = solve(key, target, difficulty)
|
||||
name = "auth/zero-target" if target_seed == 0 else (
|
||||
f"put/key-seed{key_seed:02x}-target-seed{target_seed:02x}"
|
||||
)
|
||||
vectors.append({
|
||||
"name": f"{name}/diff{difficulty}",
|
||||
"key_hex": key.hex(),
|
||||
"target_hex": target.hex(),
|
||||
"difficulty": difficulty,
|
||||
"counter": counter,
|
||||
"sum_hex": pow_hash(key, target, counter).hex(),
|
||||
"leading_zero_bits": leading_zero_bits(pow_hash(key, target, counter)),
|
||||
})
|
||||
|
||||
# Rejects derived from the first vector: off-by-one counter and wrong key.
|
||||
base = cases[2]
|
||||
key = fixture(base[0])
|
||||
target = fixture(base[1])
|
||||
good = solve(key, target, base[2])
|
||||
bad_counter = good + 1
|
||||
while leading_zero_bits(pow_hash(key, target, bad_counter)) >= base[2]:
|
||||
bad_counter += 1
|
||||
rejects.append({
|
||||
"name": "counter-misses-threshold",
|
||||
"key_hex": key.hex(),
|
||||
"target_hex": target.hex(),
|
||||
"difficulty": base[2],
|
||||
"counter": bad_counter,
|
||||
"reason": "leading_zero_bits < difficulty",
|
||||
})
|
||||
rejects.append({
|
||||
"name": "wrong-key",
|
||||
"key_hex": fixture(0xFF).hex(),
|
||||
"target_hex": target.hex(),
|
||||
"difficulty": base[2],
|
||||
"counter": good,
|
||||
"reason": "hash under a different key does not meet the target",
|
||||
})
|
||||
|
||||
config_rejects = [
|
||||
{
|
||||
"name": "difficulty-above-max",
|
||||
"difficulty": MAX_DIFFICULTY + 1,
|
||||
"reason": "configuration above MaxDifficulty is clamped/rejected",
|
||||
},
|
||||
{
|
||||
"name": "negative-difficulty",
|
||||
"difficulty": -1,
|
||||
"reason": "negative configuration is treated as disabled, never as a pass-all proof tier",
|
||||
},
|
||||
]
|
||||
|
||||
doc = {
|
||||
"domain": DOMAIN.decode(),
|
||||
"hash_spec": "BLAKE3_keyed(key, domain || target || counter_be); valid iff leading_zero_bits(sum) >= difficulty",
|
||||
"max_difficulty": MAX_DIFFICULTY,
|
||||
"vectors": vectors,
|
||||
"rejects": rejects,
|
||||
"config_rejects": config_rejects,
|
||||
}
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / "pow_vectors.json"
|
||||
path.write_text(json.dumps(doc, indent=2) + "\n")
|
||||
print(f"wrote {path} ({len(vectors)} vectors, {len(rejects)} rejects)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--emit", default="testdata/vectors", help="output directory")
|
||||
args = ap.parse_args()
|
||||
emit(Path(args.emit))
|
||||
Loading…
Add table
Reference in a new issue