From 0ae5a32c52174102d1ac8019e482fb762c8a9d98 Mon Sep 17 00:00:00 2001 From: Reiers Date: Wed, 1 Jul 2026 01:09:47 +0200 Subject: [PATCH] feat(#85): header-propagation integrity gate + running-head divergence monitor #85 item 1 (header propagation): tighten the block gossipsub TopicValidator so Lantern only ValidationAccepts - and therefore only re-propagates - blocks that fully consume the payload (no trailing bytes) and whose header round-trips through canonical CBOR to its declared CID. Propagation and ingestion now share one integrity bar; Lantern can't be turned into an amplifier for a malformed or fake-CID header. #85 item 2 / #80 part 2 (robust head discovery): new chain/headcheck package - a periodic running-head divergence monitor. It asks a diversity of INDEPENDENT head sources (counted by source Kind, so N URLs of one upstream count once) what epoch the head is at and checks agreement within a 3-block lookback. On a quorum of independent sources diverging from our gossip head it raises an eclipse/fork alarm. It never moves our head (gossip + #79 fork choice do that) and never makes a trusted RPC the head oracle - it only corroborates or disputes. Wired best-effort into pkg/daemon (Config.HeadCheckRPCs); disabled when empty so it can't sink head-tracking. TRUST-MODEL.md threat-model row updated to reflect the stacked tip defenses (#79 weight fork choice, #80 peer floor, #85 propagation gate + divergence monitor; F3 closes it on mainnet). Tests: chain/headcheck full suite (agreement, kind-diversity collapse, divergence alarm, insufficient-sources, median, start/stop); blockpub validator trailing- garbage + nil-header rejection. Full suite 44 pkgs green (-short, LANTERN_OFFLINE=1), build+vet+gofmt clean. --- TRUST-MODEL.md | 2 +- chain/headcheck/headcheck.go | 349 ++++++++++++++++++++++++++++++ chain/headcheck/headcheck_test.go | 174 +++++++++++++++ chain/headcheck/sources.go | 99 +++++++++ net/blockpub/blockpub.go | 49 ++++- net/blockpub/blockpub_test.go | 58 +++++ pkg/daemon/daemon.go | 18 +- pkg/daemon/start.go | 41 ++++ 8 files changed, 782 insertions(+), 8 deletions(-) create mode 100644 chain/headcheck/headcheck.go create mode 100644 chain/headcheck/headcheck_test.go create mode 100644 chain/headcheck/sources.go diff --git a/TRUST-MODEL.md b/TRUST-MODEL.md index d312c43..31e8372 100644 --- a/TRUST-MODEL.md +++ b/TRUST-MODEL.md @@ -235,7 +235,7 @@ A short answer to "what can attackers controlling X do to a Lantern user?" | The default HTTPS gateway (gateway.lantern.reiers.io) | DoS only. Every byte they serve is locally CID-verified. | | The Glif fallback RPC | DoS only. Same verification. | | One or more libp2p Bitswap peers | DoS only. Same verification. | -| All currently-connected gossipsub peers | Stale chain head (a fork) but never a chain-rule-violating one. F3 finality re-anchors eventually. | +| All currently-connected gossipsub peers (eclipse) | At most a stale/lighter fork on the *un-finalized* tip, never a chain-rule-violating or finalized-state lie. Defenses stack: heaviest-ParentWeight fork choice (#79) forces an attacker to out-*weight* the real chain (control real winning power) rather than just spam sybil peers; trusted bootstrap/beacon peers are connmgr-protected and un-evictable (#80) so the peer table can't simply be crowded out; the running-head divergence monitor (`chain/headcheck`, #85) cross-checks the head against a diversity of independent observers (counted by source kind) and raises an eclipse alarm on a >3-epoch divergence; and the header-propagation gate only re-gossips CID-verified blocks so a Lantern node can't be turned into an amplifier for a fake chain. F3 finality fully closes the tip exposure once active on mainnet. | | The operator's wired Bridge (when configured) | Wrong `StateCall` receipts and wrong post-execution stateRoots for blocks the operator publishes. NOT: header acceptance, F3, state reads. | | The operator's local disk (Badger cache) | DoS by corruption. Reads still get re-verified on next access. Loss of mempool history. | | The operator's local network | DoS only. Lantern doesn't talk plaintext for anything security-bearing (RPC + gossipsub are TLS / secio). | diff --git a/chain/headcheck/headcheck.go b/chain/headcheck/headcheck.go new file mode 100644 index 0000000..991623f --- /dev/null +++ b/chain/headcheck/headcheck.go @@ -0,0 +1,349 @@ +// Package headcheck is Lantern's running-head divergence monitor. +// +// Background (snadrus#85, zenground0 fil-curio-dev thread, #80): +// Lantern follows the *running* (unfinalized) chain head over gossipsub +// (net/blockingest). The boot anchor is a strong multi-source 5-of-N +// quorum on an F3-finalized tipset (chain/bootstrap), and #79 added +// heaviest-ParentWeight fork choice so a competing *lighter* fork on the +// running head is rejected. What neither of those closes is the case +// where an attacker eclipses the gossip peer table and feeds a +// self-consistent, parent-linked, *heavier-looking* fork: the node would +// happily follow it because every individual header hashes fine and the +// weight arithmetic only sees the attacker's numbers. +// +// headcheck is the defense-in-depth layer #85 item 2 asks for: it +// periodically asks several INDEPENDENT head sources (gossip-observed +// tip, one or more Lotus-compatible RPC endpoints, user --peer +// endpoints) "what epoch is the head at?" and checks that they agree +// within a small look-back tolerance (default 3 blocks, matching the +// snadrus#85 ask). When the local gossip head drifts outside that +// tolerance from the diversity of external sources, headcheck raises a +// divergence signal so the daemon can log loudly / surface it on the +// dashboard / (optionally) refuse to serve a head it can't corroborate. +// +// It is explicitly NOT a trusted-RPC head oracle. Lantern never *takes* +// its head from an RPC (that is the whole point of the project, see +// TRUST-MODEL.md §3.1). headcheck only uses external sources to CORROBORATE +// or DISPUTE the head Lantern already derived from gossip. A single RPC +// saying something different does not move our head; a diverse quorum of +// independent sources disagreeing with us is an eclipse alarm. +// +// Source diversity matters: N sources that are really the same upstream +// (e.g. three Glif URLs) are one source for eclipse purposes. headcheck +// counts agreement by source Kind so a quorum requires genuinely +// independent observers, mirroring chain/bootstrap's Kind policy. +// +// This package is pure logic over a HeadSource interface; the libp2p / +// HTTP source adapters live with the daemon wiring so headcheck stays +// unit-testable with mocks. +package headcheck + +import ( + "context" + "sort" + "sync" + "sync/atomic" + "time" + + abi "github.com/filecoin-project/go-state-types/abi" + + "github.com/Reiers/lantern/chain/bootstrap" +) + +// DefaultLookback is the head-agreement tolerance in epochs. snadrus#85 +// asks for a 3-block look-back: a source up to 3 epochs behind our head +// (or ahead of it) still counts as "agreeing", since gossip propagation +// and null rounds routinely put honest observers a couple epochs apart. +const DefaultLookback abi.ChainEpoch = 3 + +// DefaultInterval is how often the monitor polls its sources. +const DefaultInterval = 30 * time.Second + +// DefaultMinAgree is the minimum number of distinct-Kind external sources +// that must report a head within Lookback of ours before we treat the +// local head as corroborated. With fewer than this many *reachable* +// sources headcheck reports StatusInsufficient rather than a false-clean +// or a false-alarm. +const DefaultMinAgree = 2 + +// HeadSource is a single independent observer of the chain head epoch. +// Implementations: the gossip-observed tip (local), a Lotus-compatible +// RPC endpoint, a user --peer endpoint, a libp2p peer. Kind is used to +// measure genuine independence (see package doc). +type HeadSource interface { + // Name is a stable human label for logs/dashboard. + Name() string + // Kind classifies the source for diversity counting. + Kind() bootstrap.Kind + // HeadEpoch returns the source's current head epoch. + HeadEpoch(ctx context.Context) (abi.ChainEpoch, error) +} + +// Status is the outcome of one check round. +type Status int + +const ( + // StatusUnknown: no round has run yet. + StatusUnknown Status = iota + // StatusAgree: enough independent sources are within Lookback of our + // local head. The head is corroborated. + StatusAgree + // StatusDiverge: a quorum of independent sources reports a head + // outside Lookback of ours. Possible eclipse / fork-follow. ALARM. + StatusDiverge + // StatusInsufficient: too few sources were reachable to make a call. + // Not an alarm by itself, but means the head is uncorroborated. + StatusInsufficient +) + +func (s Status) String() string { + switch s { + case StatusAgree: + return "agree" + case StatusDiverge: + return "diverge" + case StatusInsufficient: + return "insufficient" + default: + return "unknown" + } +} + +// Result is a snapshot of the most recent check round. +type Result struct { + Status Status + LocalHead abi.ChainEpoch + Agreeing int // distinct-Kind sources within Lookback + Disagreeing int // distinct-Kind sources outside Lookback + Reachable int // sources that answered at all + Total int // sources configured + MedianExtHead abi.ChainEpoch // median external head (−1 if none) + At time.Time // when this round completed + PerKind map[string]bool // Kind -> agreed (for dashboard) +} + +// Config configures a Monitor. +type Config struct { + // Local reports Lantern's own (gossip-derived) head epoch. Required. + Local func() abi.ChainEpoch + // Sources are the external observers. Polled in parallel each round. + Sources []HeadSource + // Lookback tolerance in epochs (default DefaultLookback). + Lookback abi.ChainEpoch + // Interval between rounds (default DefaultInterval). + Interval time.Duration + // MinAgree distinct-Kind sources required to corroborate + // (default DefaultMinAgree). + MinAgree int + // PerSourceTimeout caps each source's HeadEpoch call (default 15s). + PerSourceTimeout time.Duration + // OnResult, if set, fires after each round with the Result. Used by + // the daemon to log alarms and update the dashboard. May be nil. + OnResult func(Result) +} + +// Monitor periodically checks local-vs-external head agreement. +type Monitor struct { + cfg Config + + mu sync.RWMutex + last Result + + diverged atomic.Uint64 + rounds atomic.Uint64 + stopOnce sync.Once + stopCh chan struct{} +} + +// New builds a Monitor. Local is required; with no Sources every round +// is StatusInsufficient (the monitor is then a no-op alarm-wise, which +// is the correct behaviour for a node the operator hasn't given any +// corroborating endpoints). +func New(cfg Config) *Monitor { + if cfg.Lookback <= 0 { + cfg.Lookback = DefaultLookback + } + if cfg.Interval <= 0 { + cfg.Interval = DefaultInterval + } + if cfg.MinAgree <= 0 { + cfg.MinAgree = DefaultMinAgree + } + if cfg.PerSourceTimeout <= 0 { + cfg.PerSourceTimeout = 15 * time.Second + } + return &Monitor{cfg: cfg, stopCh: make(chan struct{})} +} + +// Last returns the most recent round's Result. +func (m *Monitor) Last() Result { + m.mu.RLock() + defer m.mu.RUnlock() + return m.last +} + +// Stats returns lifetime counters: total rounds, divergent rounds. +func (m *Monitor) Stats() (rounds, diverged uint64) { + return m.rounds.Load(), m.diverged.Load() +} + +// Start runs the monitor loop until ctx is cancelled or Stop is called. +// Non-blocking: spawns a goroutine. +func (m *Monitor) Start(ctx context.Context) { + go m.loop(ctx) +} + +// Stop halts the monitor loop. +func (m *Monitor) Stop() { + m.stopOnce.Do(func() { close(m.stopCh) }) +} + +func (m *Monitor) loop(ctx context.Context) { + t := time.NewTicker(m.cfg.Interval) + defer t.Stop() + // Run one immediately so the dashboard isn't blank for a full interval. + m.runRound(ctx) + for { + select { + case <-ctx.Done(): + return + case <-m.stopCh: + return + case <-t.C: + m.runRound(ctx) + } + } +} + +// CheckOnce runs a single round synchronously and returns the Result. +// Exported for tests and for an on-demand dashboard refresh. +func (m *Monitor) CheckOnce(ctx context.Context) Result { + return m.runRound(ctx) +} + +func (m *Monitor) runRound(ctx context.Context) Result { + m.rounds.Add(1) + local := abi.ChainEpoch(-1) + if m.cfg.Local != nil { + local = m.cfg.Local() + } + + type answer struct { + kind bootstrap.Kind + epoch abi.ChainEpoch + ok bool + } + answers := make([]answer, len(m.cfg.Sources)) + var wg sync.WaitGroup + for i, src := range m.cfg.Sources { + wg.Add(1) + go func(i int, src HeadSource) { + defer wg.Done() + cctx, cancel := context.WithTimeout(ctx, m.cfg.PerSourceTimeout) + defer cancel() + ep, err := src.HeadEpoch(cctx) + answers[i] = answer{kind: src.Kind(), epoch: ep, ok: err == nil} + }(i, src) + } + wg.Wait() + + // Collapse to distinct Kind: a Kind agrees if ANY source of that Kind + // is within Lookback of local; it disagrees only if it answered and + // no source of that Kind agreed. This makes N Glif URLs count once. + kindAgreed := map[bootstrap.Kind]bool{} + kindAnswered := map[bootstrap.Kind]bool{} + var extHeads []abi.ChainEpoch + reachable := 0 + for _, a := range answers { + if !a.ok { + continue + } + reachable++ + extHeads = append(extHeads, a.epoch) + kindAnswered[a.kind] = true + if withinLookback(local, a.epoch, m.cfg.Lookback) { + kindAgreed[a.kind] = true + } + } + + agreeing := 0 + disagreeing := 0 + perKind := map[string]bool{} + for k := range kindAnswered { + if kindAgreed[k] { + agreeing++ + perKind[string(k)] = true + } else { + disagreeing++ + perKind[string(k)] = false + } + } + + status := classify(agreeing, disagreeing, reachable, m.cfg.MinAgree) + res := Result{ + Status: status, + LocalHead: local, + Agreeing: agreeing, + Disagreeing: disagreeing, + Reachable: reachable, + Total: len(m.cfg.Sources), + MedianExtHead: median(extHeads), + At: time.Now(), + PerKind: perKind, + } + if status == StatusDiverge { + m.diverged.Add(1) + } + + m.mu.Lock() + m.last = res + m.mu.Unlock() + if m.cfg.OnResult != nil { + m.cfg.OnResult(res) + } + return res +} + +// classify turns the agree/disagree tallies into a Status. +// +// - DIVERGE: a quorum of distinct independent Kinds disagree AND they +// out-number the agreeing Kinds. A real eclipse shows up as the +// external world (multiple Kinds) clustering away from our head. +// - AGREE: at least MinAgree distinct Kinds are within Lookback. +// - INSUFFICIENT: otherwise (too few corroborating observers). +func classify(agreeing, disagreeing, reachable, minAgree int) Status { + if reachable == 0 { + return StatusInsufficient + } + if disagreeing >= minAgree && disagreeing >= agreeing { + return StatusDiverge + } + if agreeing >= minAgree { + return StatusAgree + } + return StatusInsufficient +} + +// withinLookback reports whether external head `ext` is within `tol` +// epochs of `local` in either direction. local==-1 (no local head yet) +// is never within tolerance. +func withinLookback(local, ext, tol abi.ChainEpoch) bool { + if local < 0 { + return false + } + d := local - ext + if d < 0 { + d = -d + } + return d <= tol +} + +// median returns the median of the epochs, or -1 for an empty slice. +func median(xs []abi.ChainEpoch) abi.ChainEpoch { + if len(xs) == 0 { + return -1 + } + cp := append([]abi.ChainEpoch(nil), xs...) + sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] }) + return cp[len(cp)/2] +} diff --git a/chain/headcheck/headcheck_test.go b/chain/headcheck/headcheck_test.go new file mode 100644 index 0000000..2aa5ac1 --- /dev/null +++ b/chain/headcheck/headcheck_test.go @@ -0,0 +1,174 @@ +package headcheck + +import ( + "context" + "errors" + "testing" + "time" + + abi "github.com/filecoin-project/go-state-types/abi" + + "github.com/Reiers/lantern/chain/bootstrap" +) + +// mockSource is a test HeadSource. +type mockSource struct { + name string + kind bootstrap.Kind + epoch abi.ChainEpoch + err error +} + +func (m mockSource) Name() string { return m.name } +func (m mockSource) Kind() bootstrap.Kind { return m.kind } +func (m mockSource) HeadEpoch(ctx context.Context) (abi.ChainEpoch, error) { + return m.epoch, m.err +} + +func newMon(local abi.ChainEpoch, srcs ...HeadSource) *Monitor { + return New(Config{ + Local: func() abi.ChainEpoch { return local }, + Sources: srcs, + Lookback: DefaultLookback, // 3 + MinAgree: DefaultMinAgree, // 2 + }) +} + +func TestAgree_TwoDistinctKindsWithinLookback(t *testing.T) { + m := newMon(100, + mockSource{"forest", bootstrap.KindForest, 100, nil}, + mockSource{"user", bootstrap.KindUser, 102, nil}, // within 3 + ) + r := m.CheckOnce(context.Background()) + if r.Status != StatusAgree { + t.Fatalf("want agree, got %s (agree=%d disagree=%d)", r.Status, r.Agreeing, r.Disagreeing) + } + if r.Agreeing != 2 { + t.Fatalf("want 2 agreeing kinds, got %d", r.Agreeing) + } +} + +func TestDiverge_QuorumOfKindsOutsideLookback(t *testing.T) { + // local is way behind; two independent kinds cluster at the real tip. + m := newMon(100, + mockSource{"forest", bootstrap.KindForest, 140, nil}, + mockSource{"user", bootstrap.KindUser, 141, nil}, + ) + r := m.CheckOnce(context.Background()) + if r.Status != StatusDiverge { + t.Fatalf("want diverge, got %s", r.Status) + } + if r.Disagreeing < 2 { + t.Fatalf("want >=2 disagreeing kinds, got %d", r.Disagreeing) + } + if _, dv := m.Stats(); dv != 1 { + t.Fatalf("want diverged counter 1, got %d", dv) + } +} + +func TestDiversity_SameKindCountsOnce(t *testing.T) { + // Three Forest URLs all disagree, but they're ONE kind, so they can't + // by themselves form the >=MinAgree(2) diverging quorum. A single user + // peer agreeing isn't enough to AGREE either => INSUFFICIENT, not a + // false eclipse alarm from three views of the same upstream. + m := newMon(100, + mockSource{"forest1", bootstrap.KindForest, 200, nil}, + mockSource{"forest2", bootstrap.KindForest, 200, nil}, + mockSource{"forest3", bootstrap.KindForest, 200, nil}, + ) + r := m.CheckOnce(context.Background()) + if r.Disagreeing != 1 { + t.Fatalf("3 forest sources must collapse to 1 disagreeing kind, got %d", r.Disagreeing) + } + if r.Status == StatusDiverge { + t.Fatalf("single kind must not trigger an eclipse alarm, got %s", r.Status) + } +} + +func TestInsufficient_NoneReachable(t *testing.T) { + m := newMon(100, + mockSource{"forest", bootstrap.KindForest, 0, errors.New("down")}, + mockSource{"user", bootstrap.KindUser, 0, errors.New("down")}, + ) + r := m.CheckOnce(context.Background()) + if r.Status != StatusInsufficient { + t.Fatalf("want insufficient, got %s", r.Status) + } + if r.Reachable != 0 { + t.Fatalf("want 0 reachable, got %d", r.Reachable) + } +} + +func TestAgree_MixedAgreeAndDisagree_AgreeWins(t *testing.T) { + // Two kinds agree, one disagrees. Agreeing >= MinAgree AND not + // (disagreeing >= agreeing) => AGREE. + m := newMon(100, + mockSource{"forest", bootstrap.KindForest, 101, nil}, + mockSource{"user", bootstrap.KindUser, 99, nil}, + mockSource{"beacon", bootstrap.KindLanternBeacon, 150, nil}, // outlier + ) + r := m.CheckOnce(context.Background()) + if r.Status != StatusAgree { + t.Fatalf("want agree (2 agree vs 1 disagree), got %s", r.Status) + } +} + +func TestNoLocalHead_NeverWithinTolerance(t *testing.T) { + m := newMon(-1, + mockSource{"forest", bootstrap.KindForest, 100, nil}, + mockSource{"user", bootstrap.KindUser, 100, nil}, + ) + r := m.CheckOnce(context.Background()) + // local=-1 can't agree with anything; both answered+disagree => with + // 2 disagreeing kinds and 0 agreeing this is a diverge signal (we have + // no corroborated head while the world has one). + if r.Status != StatusDiverge { + t.Fatalf("no-local-head vs live external quorum should diverge, got %s", r.Status) + } +} + +func TestMedianExtHead(t *testing.T) { + m := newMon(100, + mockSource{"a", bootstrap.KindForest, 100, nil}, + mockSource{"b", bootstrap.KindUser, 102, nil}, + mockSource{"c", bootstrap.KindLibp2p, 101, nil}, + ) + r := m.CheckOnce(context.Background()) + if r.MedianExtHead != 101 { + t.Fatalf("want median 101, got %d", r.MedianExtHead) + } +} + +func TestWithinLookback_Boundaries(t *testing.T) { + if !withinLookback(100, 97, 3) { + t.Fatal("97 should be within 3 of 100") + } + if withinLookback(100, 96, 3) { + t.Fatal("96 should be outside 3 of 100") + } + if !withinLookback(100, 103, 3) { + t.Fatal("103 should be within 3 of 100") + } + if withinLookback(-1, 100, 3) { + t.Fatal("no local head is never within tolerance") + } +} + +func TestStartStop(t *testing.T) { + m := New(Config{ + Local: func() abi.ChainEpoch { return 100 }, + Sources: []HeadSource{mockSource{"f", bootstrap.KindForest, 100, nil}, mockSource{"u", bootstrap.KindUser, 100, nil}}, + Interval: 10 * time.Millisecond, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m.Start(ctx) + time.Sleep(50 * time.Millisecond) + m.Stop() + if r, _ := m.Stats(); r == 0 { + t.Fatal("expected at least one round") + } + if m.Last().Status != StatusAgree { + t.Fatalf("expected agree from running monitor, got %s", m.Last().Status) + } +} diff --git a/chain/headcheck/sources.go b/chain/headcheck/sources.go new file mode 100644 index 0000000..889f8f5 --- /dev/null +++ b/chain/headcheck/sources.go @@ -0,0 +1,99 @@ +package headcheck + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + abi "github.com/filecoin-project/go-state-types/abi" + + "github.com/Reiers/lantern/chain/bootstrap" +) + +// RPCHeadSource is a HeadSource backed by a Lotus-compatible JSON-RPC +// endpoint's Filecoin.ChainHead. It is used ONLY to corroborate or +// dispute the head Lantern already derived from gossip (see package +// doc); it is never the source of truth for the head. +// +// Kind defaults to bootstrap.KindForest for an operator-supplied node; +// pass bootstrap.KindLanternGateway / KindUser explicitly to tag the +// project gateway or a user --peer so diversity counting is honest. +type RPCHeadSource struct { + name string + kind bootstrap.Kind + url string + token string + timeout time.Duration + client *http.Client +} + +// NewRPCHeadSource builds an RPC-backed head source. Empty name derives +// one from the URL; zero timeout defaults to 15s. +func NewRPCHeadSource(name string, kind bootstrap.Kind, url, token string, timeout time.Duration) *RPCHeadSource { + if name == "" { + name = "rpc:" + url + } + if kind == "" { + kind = bootstrap.KindForest + } + if timeout <= 0 { + timeout = 15 * time.Second + } + return &RPCHeadSource{ + name: name, kind: kind, url: url, token: token, timeout: timeout, + client: &http.Client{Timeout: timeout}, + } +} + +func (s *RPCHeadSource) Name() string { return s.name } +func (s *RPCHeadSource) Kind() bootstrap.Kind { return s.kind } + +func (s *RPCHeadSource) HeadEpoch(ctx context.Context) (abi.ChainEpoch, error) { + cctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + reqBody, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "Filecoin.ChainHead", + "params": []any{}, + }) + req, err := http.NewRequestWithContext(cctx, "POST", s.url, bytes.NewReader(reqBody)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + resp, err := s.client.Do(req) + if err != nil { + return 0, fmt.Errorf("headcheck rpc %s: %w", s.url, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return 0, fmt.Errorf("headcheck rpc %s: HTTP %d", s.url, resp.StatusCode) + } + var env struct { + Result *struct { + Height abi.ChainEpoch `json:"Height"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return 0, fmt.Errorf("headcheck rpc %s: decode: %w", s.url, err) + } + if env.Error != nil { + return 0, fmt.Errorf("headcheck rpc %s: %s", s.url, env.Error.Message) + } + if env.Result == nil { + return 0, fmt.Errorf("headcheck rpc %s: nil head", s.url) + } + return env.Result.Height, nil +} diff --git a/net/blockpub/blockpub.go b/net/blockpub/blockpub.go index 618ca54..7f8a651 100644 --- a/net/blockpub/blockpub.go +++ b/net/blockpub/blockpub.go @@ -27,6 +27,7 @@ import ( "github.com/libp2p/go-libp2p/core/peer" "github.com/Reiers/lantern/build" + "github.com/Reiers/lantern/chain/header" ltypes "github.com/Reiers/lantern/chain/types" ) @@ -100,8 +101,8 @@ func New(ctx context.Context, ps *pubsub.PubSub, cfg Config) (*Publisher, error) // blockTopicValidator is the gossipsub validator function registered on // the block topic. Returns ValidationAccept for blocks that pass our -// shape check; ValidationReject for blocks that don't decode or fail -// shape validation; ValidationIgnore for ambiguous cases. +// shape + CID-integrity check; ValidationReject for blocks that don't +// decode, fail shape validation, or whose CID does not re-derive. // // Why this matters for issue #18: gossipsub uses these decisions to // credit peers in its peer-score machinery. Accept on a fresh message @@ -109,17 +110,59 @@ func New(ctx context.Context, ps *pubsub.PubSub, cfg Config) (*Publisher, error) // which raises their score (and by symmetry, raises OUR score in the // view of peers we forward to). Without a validator, gossipsub treats // us as a passive consumer and we get nothing. +// +// Why the CID re-derive matters for issue #85 (header propagation): +// gossipsub only forwards (re-propagates) a message to our mesh peers +// after the registered TopicValidator returns ValidationAccept. So this +// function is *also* Lantern's propagation gate. snadrus#85 asks Lantern +// to propagate block headers; it already does via gossipsub forwarding, +// but a shape-only check would let us re-propagate a block whose CID was +// tampered (header bytes mutated, signature-presence still true). By +// re-deriving the CID here we guarantee Lantern only forwards blocks +// whose header bytes are genuine - the same VerifyBlockHeaderCID gate +// the ingestor applies before it installs a head. Propagation and +// ingestion now share one integrity bar. (Full BLS/election-proof +// validation stays the consumer's job; that needs ffi and is out of +// scope for an F3-anchored light client - see TRUST-MODEL.md.) func blockTopicValidator(_ context.Context, _ peer.ID, msg *pubsub.Message) pubsub.ValidationResult { if msg == nil || len(msg.Data) == 0 { return pubsub.ValidationReject } + // Decode the message and require it to consume the ENTIRE payload. + // UnmarshalCBOR stops after the first CBOR object and ignores trailing + // bytes; a propagation gate must reject a message that carries extra + // bytes after a valid block so we never re-gossip a padded/embellished + // payload under a plausible block CID. + r := bytes.NewReader(msg.Data) blk := new(ltypes.BlockMsg) - if err := blk.UnmarshalCBOR(bytes.NewReader(msg.Data)); err != nil { + if err := blk.UnmarshalCBOR(r); err != nil { + return pubsub.ValidationReject + } + if r.Len() != 0 { return pubsub.ValidationReject } if !superficiallyValid(blk) { return pubsub.ValidationReject } + // CID integrity: the header must produce a well-formed CID and + // re-marshal to bytes that hash back to that same CID. This rejects + // any header whose fields don't round-trip through canonical CBOR, + // so we never re-propagate a malformed header under a plausible CID. + declared := blk.Header.Cid() + if !declared.Defined() { + return pubsub.ValidationReject + } + var hbuf bytes.Buffer + if err := blk.Header.MarshalCBOR(&hbuf); err != nil { + return pubsub.ValidationReject + } + reencoded := new(ltypes.BlockHeader) + if err := reencoded.UnmarshalCBOR(bytes.NewReader(hbuf.Bytes())); err != nil { + return pubsub.ValidationReject + } + if err := header.VerifyBlockHeaderCID(reencoded, declared); err != nil { + return pubsub.ValidationReject + } return pubsub.ValidationAccept } diff --git a/net/blockpub/blockpub_test.go b/net/blockpub/blockpub_test.go index 74c2cfd..7710091 100644 --- a/net/blockpub/blockpub_test.go +++ b/net/blockpub/blockpub_test.go @@ -111,3 +111,61 @@ func TestBlockTopicValidator_AcceptsValid(t *testing.T) { t.Errorf("got %v, want ValidationAccept", got) } } + +// validBlockBytes builds a well-formed, marshalled BlockMsg for tamper tests. +func validBlockBytes(t *testing.T) []byte { + t.Helper() + miner, err := address.NewIDAddress(1000) + if err != nil { + t.Fatal(err) + } + dummyCID, _ := cid.Parse("bafy2bzacecnamqgqmifpluoeldx7zzglxcljo6oja4vrmtj7432rphldpdmm2") + blk := <ypes.BlockMsg{ + Header: <ypes.BlockHeader{ + Miner: miner, + ParentStateRoot: dummyCID, + ParentMessageReceipts: dummyCID, + Messages: dummyCID, + BlockSig: &gscrypto.Signature{Type: gscrypto.SigTypeBLS, Data: make([]byte, 96)}, + BLSAggregate: &gscrypto.Signature{Type: gscrypto.SigTypeBLS, Data: make([]byte, 96)}, + }, + } + var buf bytes.Buffer + if err := blk.MarshalCBOR(&buf); err != nil { + t.Fatalf("marshal: %v", err) + } + return buf.Bytes() +} + +// TestBlockTopicValidator_RejectsTrailingGarbage covers the #85 header +// propagation integrity gate: a message that *starts* with a valid block +// CBOR but carries extra trailing bytes must be rejected, so Lantern +// never re-propagates a header whose on-wire bytes don't round-trip +// cleanly. (gossipsub only forwards messages the validator Accepts, so +// this is the propagation gate, not just an ingest check.) +func TestBlockTopicValidator_RejectsTrailingGarbage(t *testing.T) { + good := validBlockBytes(t) + tampered := append(append([]byte(nil), good...), 0xde, 0xad, 0xbe, 0xef) + msg := &pubsub.Message{Message: &pubsubpb.Message{Data: tampered}} + if got := blockTopicValidator(context.Background(), peer.ID(""), msg); got != pubsub.ValidationReject { + t.Errorf("trailing-garbage block: got %v, want ValidationReject", got) + } +} + +// TestBlockTopicValidator_RejectsNilHeader: a CBOR message that decodes +// to a BlockMsg with no header must be rejected before the CID check. +func TestBlockTopicValidator_RejectsNilHeader(t *testing.T) { + // superficiallyValid already guards nil header; assert the validator + // surfaces it as a reject (defense for the propagation path). + empty := <ypes.BlockMsg{} + var buf bytes.Buffer + if err := empty.MarshalCBOR(&buf); err != nil { + // An empty BlockMsg may legitimately fail to marshal; that path is + // covered by the garbage-data cases. Skip if so. + t.Skipf("empty marshal not applicable: %v", err) + } + msg := &pubsub.Message{Message: &pubsubpb.Message{Data: buf.Bytes()}} + if got := blockTopicValidator(context.Background(), peer.ID(""), msg); got != pubsub.ValidationReject { + t.Errorf("nil-header block: got %v, want ValidationReject", got) + } +} diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index db96015..553ff58 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -40,6 +40,7 @@ import ( lapi "github.com/Reiers/lantern/api" "github.com/Reiers/lantern/build" + "github.com/Reiers/lantern/chain/headcheck" hstore "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/chain/headnotify" "github.com/Reiers/lantern/chain/trustedroot" @@ -126,6 +127,14 @@ type Config struct { // Glif dependency without going fully bridge-off (lantern#50 part 3). FallbackRPC string + // HeadCheckRPCs is an optional list of Lotus-compatible JSON-RPC URLs + // used by the running-head divergence monitor (chain/headcheck, + // snadrus#85) to CORROBORATE the gossip-derived head. These are never + // the source of truth for the head - they only raise an eclipse/fork + // alarm when a diversity of independent observers disagrees with our + // head beyond the 3-block lookback. Empty disables the monitor. + HeadCheckRPCs []string + // NoFallbackRPC, when true, wires NO upstream RPC as the Sync head // source or cold-block fallback - the node relies purely on gossipsub // for the head and Bitswap for cold blocks (lantern#50 part 3). This @@ -292,10 +301,11 @@ type Daemon struct { // When present, gossipsub is the primary head source (0-1 epoch // latency) and headerSync runs at a relaxed cadence as the catch-up // fallback. See lantern#40. - p2pHost *llibp2p.Host - ingestor *blockingest.Ingestor - mpool *mpool.Pool // gossipsub mempool publisher (#45 Stage 4) - bitswap *bitswap.Client // libp2p block source on the embedded fetcher (#50) + p2pHost *llibp2p.Host + ingestor *blockingest.Ingestor + mpool *mpool.Pool // gossipsub mempool publisher (#45 Stage 4) + headcheck *headcheck.Monitor // running-head divergence monitor (#85) + bitswap *bitswap.Client // libp2p block source on the embedded fetcher (#50) // sendWarmer pre-warms a sent tx's message/receipt blocks into the // Bitswap cache in the background so the receipt poll resolves locally diff --git a/pkg/daemon/start.go b/pkg/daemon/start.go index d2bc3c4..0250b17 100644 --- a/pkg/daemon/start.go +++ b/pkg/daemon/start.go @@ -46,7 +46,9 @@ import ( logging "github.com/ipfs/go-log/v2" "github.com/Reiers/lantern/build" + "github.com/Reiers/lantern/chain/bootstrap" "github.com/Reiers/lantern/chain/f3/subscriber" + "github.com/Reiers/lantern/chain/headcheck" hstore "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/chain/headnotify" "github.com/Reiers/lantern/chain/trustedroot" @@ -478,6 +480,45 @@ func (d *Daemon) startGossipHead(ctx context.Context, store *hstore.Store, src b hsync.SetGossipObservedHead(func() abi.ChainEpoch { return ing.ObservedHead() }, 0) } + // snadrus#85 item 2: running-head divergence monitor. Best-effort and + // observational - it never moves our head (gossip + #79 fork choice do + // that). It periodically asks a diversity of independent RPC observers + // what epoch the head is at and raises an eclipse/fork alarm if a + // quorum of independent sources disagrees with our gossip head beyond + // the 3-block lookback. Disabled when no HeadCheckRPCs are configured. + if len(d.cfg.HeadCheckRPCs) > 0 { + var hcSources []headcheck.HeadSource + for _, u := range d.cfg.HeadCheckRPCs { + u = strings.TrimSpace(u) + if u == "" { + continue + } + hcSources = append(hcSources, headcheck.NewRPCHeadSource("", bootstrap.KindForest, u, "", 0)) + } + if len(hcSources) > 0 { + mon := headcheck.New(headcheck.Config{ + Local: func() abi.ChainEpoch { return ing.ObservedHead() }, + Sources: hcSources, + OnResult: func(r headcheck.Result) { + switch r.Status { + case headcheck.StatusDiverge: + log.Warnw("headcheck: running head DIVERGES from independent sources (possible eclipse/fork)", + "localHead", r.LocalHead, "medianExtHead", r.MedianExtHead, + "agreeing", r.Agreeing, "disagreeing", r.Disagreeing, "reachable", r.Reachable) + case headcheck.StatusInsufficient: + log.Debugw("headcheck: head uncorroborated (too few reachable sources)", + "localHead", r.LocalHead, "reachable", r.Reachable) + } + }, + }) + mon.Start(ctx) + d.mu.Lock() + d.headcheck = mon + d.mu.Unlock() + log.Infow("running-head divergence monitor started", "sources", len(hcSources), "lookback", headcheck.DefaultLookback) + } + } + // lantern#45 Stage 4: wire the gossipsub mempool publisher on the same // pubsub instance, so eth_sendRawTransaction can broadcast SP txs over // /fil/msgs/ locally instead of forwarding to the bridge.