- 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.
168 lines
4.9 KiB
Go
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
|
|
}
|