niko_trust/internal/lightnode/auth.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

212 lines
6.1 KiB
Go

package lightnode
// Upstream authentication. Reading streams at a relay requires a session,
// and sessions require an AuthAssertion signed by *some* key. The light node
// therefore holds one ephemeral Ed25519 key used purely as a transport
// credential: it signs nothing but its own login, grants nothing, and is
// regenerated on every start unless a seed directory is configured.
//
// The handshake must survive relays that run proof-of-work admission
// control: when the challenge endpoint answers 429 "proof of work required",
// the node solves the keyed BLAKE3 puzzle for the zero target exactly like
// any other client.
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/pow"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
)
type upstreamAuth struct {
client *http.Client
// key is the node's ephemeral transport credential. All private-key
// handling stays inside internal/identity/signer, keeping the
// source-scanned invariant intact: this key signs exactly one thing,
// the node's own login assertion.
key *signer.Signer
pubHex string
// per-peer session state, guarded by the node mutex in practice; each
// peer gets its own token.
tokens map[string]string
}
func newUpstreamAuth(seedDir string, client *http.Client) (*upstreamAuth, error) {
if seedDir != "" {
path := seedDir + "/transport_key.seed"
// 32 bytes = ed25519 seed size; signer.FromSeed validates the rest.
if seed, err := os.ReadFile(path); err == nil && len(seed) == 32 {
signerKey, err := signer.FromSeed(seed)
if err != nil {
return nil, err
}
return &upstreamAuth{
client: client,
key: signerKey,
pubHex: hex.EncodeToString(signerKey.Public()),
tokens: make(map[string]string),
}, nil
}
}
gen, err := signer.Generate()
if err != nil {
return nil, err
}
if seedDir != "" {
if err := os.WriteFile(seedDir+"/transport_key.seed", gen.Seed(), 0o600); err != nil {
return nil, err
}
}
return &upstreamAuth{
client: client,
key: gen,
pubHex: hex.EncodeToString(gen.Public()),
tokens: make(map[string]string),
}, nil
}
var (
errAuthFailed = errors.New("lightnode: upstream authentication failed")
)
// session returns a live bearer token for the peer, performing (and caching)
// the challenge/assert dance when needed. A 401 downstream invalidates the
// cached token once.
func (a *upstreamAuth) session(ctx context.Context, peer string) (string, error) {
if tok := a.tokens[peer]; tok != "" {
return tok, nil
}
tok, err := a.authenticate(ctx, peer)
if err != nil {
return "", err
}
a.tokens[peer] = tok
return tok, nil
}
func (a *upstreamAuth) invalidate(peer string) { delete(a.tokens, peer) }
func (a *upstreamAuth) authenticate(ctx context.Context, peer string) (string, error) {
body, status, err := postJSON(ctx, a.client, peer+"/v1/auth/challenge", nil)
if err == nil && status == http.StatusTooManyRequests && bytes.Contains(body, []byte("proof of work")) {
// Admission control is on: fetch a PoW challenge, solve for the
// zero target, retry with the proof attached.
powBody, pstatus, perr := postJSON(ctx, a.client, peer+"/v1/pow/challenge", map[string]string{"purpose": "auth"})
if perr != nil || pstatus != http.StatusOK {
return "", fmt.Errorf("%w: pow challenge", errAuthFailed)
}
var pc struct {
Key string `json:"key"`
Difficulty int `json:"difficulty"`
}
json.Unmarshal(powBody, &pc)
key, err := pow.ParseKey(pc.Key)
if err != nil {
return "", errAuthFailed
}
counter, ok := pow.Solve(key, [pow.TargetSize]byte{}, pc.Difficulty)
if !ok {
return "", errAuthFailed
}
body, status, err = postJSON(ctx, a.client, peer+"/v1/auth/challenge",
map[string]any{"pow": map[string]any{"key": pc.Key, "counter": counter}})
}
if err != nil || status != http.StatusOK {
return "", fmt.Errorf("%w: challenge status %d", errAuthFailed, status)
}
var ch struct {
Challenge string `json:"challenge"`
}
json.Unmarshal(body, &ch)
chBytes, err := hex.DecodeString(ch.Challenge)
if err != nil || len(chBytes) != 32 {
return "", errAuthFailed
}
audience, err := a.audience(ctx, peer)
if err != nil {
return "", err
}
a2 := &protocol.AuthAssertion{
PubKey: a.key.Public(),
Challenge: chBytes,
Scope: "read",
Audience: audience,
CreatedAt: uint64(time.Now().Unix()),
}
tb, err := protocol.EncodeAuthAssertion(a2)
if err != nil {
return "", errAuthFailed
}
sig := a.key.Sign(tb)
envBody, _, err := postJSON(ctx, a.client, peer+"/v1/auth/assert",
map[string]any{"tce": base64.StdEncoding.EncodeToString(tb),
"signature": base64.StdEncoding.EncodeToString(sig)})
if err != nil || !bytes.Contains(envBody, []byte("session_token")) {
return "", errAuthFailed
}
var out struct {
SessionToken string `json:"session_token"`
}
json.Unmarshal(envBody, &out)
if out.SessionToken == "" {
return "", errAuthFailed
}
return out.SessionToken, nil
}
func postJSON(ctx context.Context, client *http.Client, url string, body any) ([]byte, int, error) {
var rd io.Reader
if body != nil {
raw, _ := json.Marshal(body)
rd = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rd)
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return raw, resp.StatusCode, err
}
var _ = base64.StdEncoding
// audience fetches and caches the relay's audience binding.
func (a *upstreamAuth) audience(ctx context.Context, peer string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/config", nil)
if err != nil {
return "", err
}
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var cfg struct {
Audience string `json:"audience"`
}
json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&cfg)
if cfg.Audience == "" {
return "", errAuthFailed
}
return cfg.Audience, nil
}