- 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.
397 lines
10 KiB
Go
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[:]),
|
|
})
|
|
}
|