niko_trust/internal/server/checkpoint.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

397 lines
10 KiB
Go

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