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

171 lines
4.7 KiB
Go

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