- 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.
212 lines
6.1 KiB
Go
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
|
|
}
|