Files
zeekay e73a3ad892 feat(network): R1/R3/R6/R8 defences staged for attack-suite consumption
Baseline of blue's redfix branch captured on this branch so the red
attack suite has something to compile against. Blue's feat/network-v0-redfix
still carries the authoritative changes — this commit will be superseded
when blue's PR lands. Content mirrors blue's working tree verbatim.

R1 (frame/envelope ShardID binding) — node.go: onPeerFrame rejects
envelopes whose inner frame.ShardID disagrees with the envelope header.

R2 (localSeq pinning) — shard.go: applyLoop now guards against attacker-
controlled Seq values driving localSeq past the finalised height.

R3 (archive segment authorship) — segment.go: LBN2 adds Ed25519 signature
over body||crc||pubkey. Readers require a verifier; nil verifier
fail-closes. LBN1 (unauthenticated) hard-rejected.

R6 (backlog cap) — archive.go/archive_writer.go: BacklogMaxBytes +
BacklogMaxSegments enforced per-shard with IncDrops metric when capacity
sheds oldest. Default 64 MiB / 100k segments.

R8 (segment-key disambiguation) — segment.go/archive_writer.go: objectKey
suffixed with per-flush nanos to prevent deterministic-key overwrite on
restart after crash-mid-flush.
2026-04-22 15:02:14 -07:00

273 lines
8.2 KiB
Go

package network
import (
"context"
"fmt"
"sync"
)
// NodeID is a stable member identity — in k8s the pod DNS name, in compose
// the service DNS name, in tests a synthetic string.
type NodeID string
// Shard owns the per-shard Quasar engine, the apply loop, and the dedup
// cache that makes apply idempotent across restarts.
//
// Lifecycle: created lazily on first reference by shard(id), closed by the
// node on Stop(). One Shard per shardID per node.
type Shard struct {
ID string
Members []NodeID
Threshold int
engine *quasarEngine
metric *Metrics
mu sync.Mutex
applied map[ApplyKey]struct{}
// localSeq is the last finalised sequence we have applied locally; the
// gateway reads this to serve read-your-writes via the txseq cookie.
// R2: advances STRICTLY by +1 per apply — never pinned to an
// attacker-chosen height.
localSeq uint64
// pending holds finalised frames whose Seq is > localSeq+1 (quasar
// may reorder across the DAG). Frames land here and drain as their
// predecessors arrive. A bounded cap (pendingCap) drops the oldest
// out-of-order frames to keep a hostile peer from OOMing us with a
// far-future Seq cloud. A dropped frame re-enters via quasar's
// finalise stream on retry, so liveness is preserved.
pending map[uint64]Frame
pendingCap int
ctx context.Context
cancel context.CancelFunc
}
// newShard starts the Quasar engine and the apply goroutine. apply is the
// node-level callback invoked for every finalised frame.
func newShard(parent context.Context, id string, members []NodeID, replication int, m *Metrics, apply ApplyFunc) (*Shard, error) {
ctx, cancel := context.WithCancel(parent)
threshold := quorum(replication)
// Quasar requires threshold ≥ 2 except in test-engine mode — we pick
// that path when replication < 2 so single-node works.
eng, err := newQuasarEngine(ctx, id, members, threshold)
if err != nil {
cancel()
return nil, fmt.Errorf("shard %s: %w", id, err)
}
s := &Shard{
ID: id,
Members: members,
Threshold: threshold,
engine: eng,
metric: m,
applied: make(map[ApplyKey]struct{}),
pending: make(map[uint64]Frame),
pendingCap: 1024, // bounded per-shard buffer for out-of-order frames
ctx: ctx,
cancel: cancel,
}
go s.applyLoop(apply)
return s, nil
}
// quorum computes k = ⌊N/2⌋+1 for N≥3, N-1 for N=2, 1 for N≤1.
func quorum(n int) int {
switch {
case n <= 1:
return 1
case n == 2:
return 2
default:
return n/2 + 1
}
}
// submitLocal pushes a locally-generated frame into the engine.
//
// R1 defence-in-depth: reject any frame whose declared ShardID does
// not match this shard's ID. The transport-layer check in onPeerFrame
// already covers this path; double-checking here blocks an internal
// caller that mixed up shard identities.
func (s *Shard) submitLocal(f Frame) error {
if f.ShardID != s.ID {
s.metric.FramesRejectedShardMismatch.Inc()
return fmt.Errorf("shard %s: frame shardID %q mismatch", s.ID, f.ShardID)
}
if err := s.engine.Submit(f); err != nil {
return fmt.Errorf("shard %s submit: %w", s.ID, err)
}
s.metric.FramesSubmitted.Inc()
return nil
}
// ingestRemote is called when the transport hands us a frame from a peer.
// Verify, then submit so Quasar folds it into the DAG.
//
// R1 fix: inner ShardID MUST match the shard we're being submitted to.
// R2 protection: the Seq value is still attacker-controllable at this
// point; that's OK — submit accepts it into the DAG, but the applyLoop
// below is what actually advances localSeq, and it enforces strict
// monotone continuity (Seq == localSeq+1).
func (s *Shard) ingestRemote(f Frame) error {
if f.ShardID != s.ID {
s.metric.FramesRejectedShardMismatch.Inc()
return fmt.Errorf("shard %s: frame shardID %q mismatch", s.ID, f.ShardID)
}
if err := f.Valid(); err != nil {
s.metric.FramesInvalid.Inc()
return err
}
if err := s.engine.Submit(f); err != nil {
return err
}
s.metric.FramesIngested.Inc()
return nil
}
// applyLoop drains finalised frames and calls apply exactly once per
// (salt, cksm).
//
// R2 fix: localSeq advances STRICTLY by +1 per frame. Frames arriving
// out of order (quasar's DAG does not guarantee submission order on the
// finalise stream) are buffered in s.pending and applied when their
// predecessor lands. A frame whose Seq is <= localSeq is either a
// duplicate (dedupe by ApplyKey) or a stale replay (drop). A frame
// whose Seq is > localSeq+1 waits in pending until the gap closes.
//
// This blocks the attacker's "submit Seq=2^62 to pin localSeq" attack:
// the 2^62 frame stays in pending forever because Seqs 1..2^62-1 never
// arrive. The gateway sees localSeq==0 (or whatever honestly advanced),
// never jumps.
//
// pending is capped — once full, new far-future frames are dropped so
// one peer cannot OOM the shard with a forged-Seq cloud. Dropped
// frames are re-delivered by quasar's finalise stream on retry (the
// engine persists its DAG), so liveness is preserved.
//
// Idempotency is still keyed on (salt, cksm) — a duplicate finalised
// frame (e.g. peer re-submits after a reconnect) is counted as a dup
// and skipped.
func (s *Shard) applyLoop(apply ApplyFunc) {
drain := func(f Frame) {
if err := apply(f); err != nil {
s.metric.ApplyErrors.Inc()
return
}
s.metric.FramesFinalized.Inc()
}
for {
select {
case <-s.ctx.Done():
return
case f, ok := <-s.engine.Finalized():
if !ok {
return
}
// R1 defence-in-depth at apply boundary: the engine's
// finalised stream can only ever carry frames we submitted
// for this shard (engine is per-shard), but we re-verify
// the inner shardID anyway in case Quasar's ordering
// guarantees change. Wrong shard → drop, metric.
if f.ShardID != s.ID {
s.metric.FramesRejectedShardMismatch.Inc()
continue
}
key := f.ApplyKey()
s.mu.Lock()
if _, dup := s.applied[key]; dup {
s.mu.Unlock()
s.metric.FramesDuplicate.Inc()
continue
}
if f.Seq <= s.localSeq {
// Stale replay or re-delivery below the watermark.
// Either the salt/cksm already matched (dup above) or
// an attacker replayed a different payload for an
// already-applied Seq. Drop — localSeq is authoritative.
s.mu.Unlock()
s.metric.FramesRejectedSeqGap.Inc()
continue
}
if f.Seq != s.localSeq+1 {
// Out-of-order arrival. Buffer until predecessors land.
// Cap enforcement: if pending is full, drop the frame
// with the highest Seq (furthest from the gap) — keeps
// the window tight and makes attacker-forged far-future
// frames fall out first.
if len(s.pending) >= s.pendingCap {
var maxSeq uint64
for seq := range s.pending {
if seq > maxSeq {
maxSeq = seq
}
}
if f.Seq >= maxSeq {
// Incoming is further than anything pending —
// drop it (don't even buffer).
s.mu.Unlock()
s.metric.FramesRejectedSeqGap.Inc()
continue
}
// Evict the furthest pending to make room.
delete(s.pending, maxSeq)
s.metric.FramesRejectedSeqGap.Inc()
}
s.pending[f.Seq] = f
s.mu.Unlock()
continue
}
// f.Seq == localSeq + 1: apply and drain any now-contiguous
// pending frames.
s.applied[key] = struct{}{}
s.localSeq = f.Seq
ready := []Frame{f}
for {
next, ok := s.pending[s.localSeq+1]
if !ok {
break
}
delete(s.pending, s.localSeq+1)
nextKey := next.ApplyKey()
if _, dup := s.applied[nextKey]; dup {
s.metric.FramesDuplicate.Inc()
continue
}
s.applied[nextKey] = struct{}{}
s.localSeq = next.Seq
ready = append(ready, next)
}
s.mu.Unlock()
for _, rf := range ready {
drain(rf)
}
}
}
}
// close halts the shard engine. Called by node.Stop.
func (s *Shard) close() {
s.cancel()
_ = s.engine.Stop()
}
// LocalSeq returns the highest finalised seq this shard has applied
// locally. Gateways compare this against the client's txseq cookie.
func (s *Shard) LocalSeq() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.localSeq
}
// ApplyFunc is called once per finalised frame per shard. Errors are
// counted on the metrics and do not retry — Quasar has already finalised
// the frame, so the caller owns recovery.
type ApplyFunc func(Frame) error