Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ All notable changes to Lantern.
lotus). TRUST-MODEL.md section 2.7 documents what it does and does NOT
guarantee.

- **VM-bridge cross-check auditor** ([#98](https://github.com/Reiers/lantern/issues/98)).
New opt-in `--vm-crosscheck` (requires `--vm-bridge-rpc`): once a minute
Lantern asks the operator's own Forest/Lotus bridge node for the
canonical tipset at head-3 and compares it against the local header
store. A mismatch (state-root-level attack, eclipse, or bug) raises a
loud `DIVERGE` alarm (log + counters + dashboard card) but is strictly
**observe-only**: reads stay 100% local, the head path is never blocked
or rewritten, and bridge lag / null rounds are skipped, never
false-alarmed. No new trust: the bridge is already trusted for block
production; this reuses the connection as an auditor. Pragmatic
precursor to full pure-Go re-execution (#89).

- **Wallet import/export + import-from-Lotus** ([#93](https://github.com/Reiers/lantern/issues/93)).
`lantern wallet export <addr>` / `wallet import [hex|-]` speak Lotus's
hex-KeyInfo wire format, so keys round-trip between Lotus and Lantern
Expand Down
15 changes: 13 additions & 2 deletions TRUST-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,18 @@ A malicious or compromised bridge upstream **cannot**:
In short: the bridge is bounded to message-execution semantics, on the
operator's own active path. It is NOT a chain-level oracle.

### 4.2 Recommended operator configurations
### 4.2 The bridge as an auditor (`--vm-crosscheck`, #98)

The same bridge connection can optionally AUDIT the read path: once a
minute Lantern compares its canonical tipset at head-3 against the
bridge node's and alarms loudly on divergence. This adds no new trust
(the bridge is already trusted for block production) and changes no
behavior (observe-only; reads are never answered by the bridge). It
catches the class of attack full re-execution would catch - a fabricated
chain whose headers verify but whose state was never honestly executed -
as long as the operator's bridge node is honest and unpartitioned.

### 4.3 Recommended operator configurations

| Profile | Bridge | Trust posture |
|------------------------|--------|---------------|
Expand All @@ -240,7 +251,7 @@ operator's own active path. It is NOT a chain-level oracle.
| Light + deal flow | Operator's own Forest as a sidecar | Bridge used only for the few `StateCall` paths Curio needs (e.g. storage-market PSD verification). All chain reads still verified locally. |
| **Don't do this** | A third-party public RPC | Equivalent to "trust your RPC provider." Defeats the point of running Lantern in the first place. We don't ship a default public bridge for this reason. |

### 4.3 Bridge provenance + auditability
### 4.4 Bridge provenance + auditability

`vm/bridge.Bridge.Provenance()` returns an opaque tag (`forest@<host>`,
`lotus@<host>`, etc.) that Lantern uses in trace logs. When StateCall
Expand Down
254 changes: 254 additions & 0 deletions chain/crosscheck/crosscheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Package crosscheck implements the VM-bridge auditor (#98).
//
// The VM bridge (vm/bridge) is normally a PRODUCTION dependency: it
// computes post-execution state roots for block production. This package
// turns the same connection into an AUDITOR for the read path: it
// periodically asks the operator's own Forest/Lotus node for the
// canonical tipset at a settled depth and compares it against Lantern's
// header-store view. A mismatch means Lantern's head path and the
// operator's full node disagree about the canonical chain - a state-root
// level attack, an eclipse, or a bug - and is worth a loud alarm.
//
// Why this is honest trust-wise (TRUST-MODEL 4.x): the bridge is the
// operator's own node, already trusted for block production. Using it to
// CROSS-CHECK adds no new trust; the read path stays 100% local and is
// never answered by the bridge. The check is observe-only: a divergence
// alarms (log + counter + dashboard) but never blocks or rewrites
// Lantern's head. This delivers most of the practical security value of
// full re-execution (#89 Stage C) at ~zero cost, and doubles as the
// vector generator Stage C will need.
//
// Sampling: checks run at a fixed interval (default 60s) against depth
// head-K (default 3 epochs) so legitimate near-tip reorgs don't produce
// false alarms. Null rounds are skipped (heights must match to compare).
package crosscheck

import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"time"

"github.com/filecoin-project/go-state-types/abi"

logging "github.com/ipfs/go-log/v2"

ltypes "github.com/Reiers/lantern/chain/types"
)

var log = logging.Logger("lantern/crosscheck")

// DefaultInterval is the default wall-clock spacing between checks.
const DefaultInterval = 60 * time.Second

// DefaultDepth is how many epochs behind head the check targets. 3
// epochs (~90s) is deep enough that honest near-tip reorgs have settled,
// shallow enough that an attack alarms within ~2 minutes.
const DefaultDepth = 3

// RPC is the bridge surface the checker needs. vm/bridge.ForestBridge
// (and the Bridge interface) satisfy it.
type RPC interface {
RawJSONRPC(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error)
Provenance() string
}

// Source is the local chain surface the checker audits.
// chain/header/store.Store satisfies it.
type Source interface {
Head() *ltypes.TipSet
GetTipSetByHeight(epoch abi.ChainEpoch) (*ltypes.TipSet, error)
}

// Config wires a Checker.
type Config struct {
Bridge RPC
Source Source
Interval time.Duration // 0 => DefaultInterval
Depth int // 0 => DefaultDepth
// OnDiverge, when set, fires on every confirmed divergence (after
// the height-match guard). Wire alerts/notifications here.
OnDiverge func(epoch abi.ChainEpoch, ours, theirs string)
}

// Stats is a snapshot of checker counters.
type Stats struct {
Checks uint64
Agrees uint64
Diverges uint64
Skipped uint64 // bridge lag / null round / shallow chain / errors
LastCheckedEpoch abi.ChainEpoch
LastResult string // "agree" | "DIVERGE" | "skip" | ""
Provenance string
}

// Checker periodically cross-checks the local canonical chain against
// the operator's bridge node. Observe-only: never mutates local state.
type Checker struct {
cfg Config
interval time.Duration
depth abi.ChainEpoch

checks atomic.Uint64
agrees atomic.Uint64
diverges atomic.Uint64
skipped atomic.Uint64
lastEpoch atomic.Int64
lastRes atomic.Value // string
}

// New builds a Checker. Bridge and Source are required.
func New(cfg Config) (*Checker, error) {
if cfg.Bridge == nil || cfg.Source == nil {
return nil, fmt.Errorf("crosscheck: Bridge and Source are required")
}
iv := cfg.Interval
if iv <= 0 {
iv = DefaultInterval
}
d := cfg.Depth
if d <= 0 {
d = DefaultDepth
}
c := &Checker{cfg: cfg, interval: iv, depth: abi.ChainEpoch(d)}
c.lastRes.Store("")
return c, nil
}

// Start launches the periodic check loop; returns immediately. The loop
// exits when ctx is cancelled.
func (c *Checker) Start(ctx context.Context) {
go func() {
t := time.NewTicker(c.interval)
defer t.Stop()
log.Infow("vm-bridge cross-check auditor started",
"provenance", c.cfg.Bridge.Provenance(), "interval", c.interval, "depth", int64(c.depth))
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.CheckOnce(ctx)
}
}
}()
}

// bridgeTipSet is the JSON shape of Filecoin.ChainGetTipSetByHeight.
type bridgeTipSet struct {
Cids []cidJSON `json:"Cids"`
Height int64 `json:"Height"`
Blocks []struct {
ParentStateRoot cidJSON `json:"ParentStateRoot"`
} `json:"Blocks"`
}

type cidJSON struct {
Slash string `json:"/"`
}

// CheckOnce performs one cross-check. Exposed for tests and for a
// dashboard "check now" action.
func (c *Checker) CheckOnce(ctx context.Context) {
head := c.cfg.Source.Head()
if head == nil || head.Height() <= c.depth {
c.skip("no head / shallow chain")
return
}
target := head.Height() - c.depth

ours, err := c.cfg.Source.GetTipSetByHeight(target)
if err != nil || ours == nil {
c.skip("local tipset unavailable")
return
}
if ours.Height() != target {
// Null round at target locally; comparing across a null round
// invites false positives. Skip this tick.
c.skip("null round")
return
}

params, _ := json.Marshal([]interface{}{target, nil})
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
raw, err := c.cfg.Bridge.RawJSONRPC(cctx, "Filecoin.ChainGetTipSetByHeight", params)
cancel()
if err != nil {
c.skip("bridge unreachable: " + err.Error())
return
}
var theirs bridgeTipSet
if err := json.Unmarshal(raw, &theirs); err != nil {
c.skip("bridge response parse: " + err.Error())
return
}
if theirs.Height != int64(target) {
// The bridge resolved a null round to an earlier tipset, or it
// lags behind target. Either way: not comparable this tick.
c.skip("bridge height mismatch (lag or null round)")
return
}

c.checks.Add(1)
c.lastEpoch.Store(int64(target))

if tipsetKeysEqual(ours, &theirs) {
c.agrees.Add(1)
c.lastRes.Store("agree")
return
}

// Confirmed divergence at settled depth: our canonical tipset and
// the operator's full node disagree. Loud, but observe-only.
c.diverges.Add(1)
c.lastRes.Store("DIVERGE")
oursStr := fmt.Sprintf("%v", ours.Cids())
theirsStr := fmt.Sprintf("%v", theirs.Cids)
log.Errorw("VM-BRIDGE CROSS-CHECK DIVERGENCE: local canonical tipset disagrees with bridge node",
"epoch", int64(target), "local", oursStr, "bridge", theirsStr,
"provenance", c.cfg.Bridge.Provenance())
if c.cfg.OnDiverge != nil {
c.cfg.OnDiverge(target, oursStr, theirsStr)
}
}

func (c *Checker) skip(reason string) {
c.skipped.Add(1)
c.lastRes.Store("skip")
log.Debugw("cross-check skipped", "reason", reason)
}

// tipsetKeysEqual compares our tipset's block CIDs with the bridge's,
// order-insensitively (both sides canonicalize by ticket, but a set
// compare is robust and cheap at <=15 blocks).
func tipsetKeysEqual(ours *ltypes.TipSet, theirs *bridgeTipSet) bool {
oc := ours.Cids()
if len(oc) != len(theirs.Cids) {
return false
}
set := make(map[string]struct{}, len(oc))
for _, c := range oc {
set[c.String()] = struct{}{}
}
for _, tc := range theirs.Cids {
if _, ok := set[tc.Slash]; !ok {
return false
}
}
return true
}

// Stats returns a snapshot of counters.
func (c *Checker) Stats() Stats {
res, _ := c.lastRes.Load().(string)
return Stats{
Checks: c.checks.Load(),
Agrees: c.agrees.Load(),
Diverges: c.diverges.Load(),
Skipped: c.skipped.Load(),
LastCheckedEpoch: abi.ChainEpoch(c.lastEpoch.Load()),
LastResult: res,
Provenance: c.cfg.Bridge.Provenance(),
}
}
Loading
Loading