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

12 packages green, vet/gofmt clean, protocol fuzzing stable.
2026-08-25 20:38:40 +03:00

295 lines
7.9 KiB
Go

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