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

168 lines
4.9 KiB
Go

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