From 422146a08d9bda421e6817331b625f2d1ed73f38 Mon Sep 17 00:00:00 2001 From: G0G0S <48889958+G0G0S@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:12:32 +0100 Subject: [PATCH] feat: add LtHash snapshot integrity verification Re-compute the lattice hash from all accounts after building AccountsDB and compare against the incremental manifest's LtHash. If they don't match, the snapshot is considered tampered and the build fails with an error. This prevents a DoS vector where a malicious RPC node serves a snapshot with altered account data that would only be detected much later during replay as a bankhash mismatch. - New file: verify_lthash.go (parallelized verification with 32 workers) - Modified: build_db_with_incr.go (call verification after OpenDb) --- pkg/snapshot/build_db_with_incr.go | 11 +++ pkg/snapshot/verify_lthash.go | 121 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 pkg/snapshot/verify_lthash.go diff --git a/pkg/snapshot/build_db_with_incr.go b/pkg/snapshot/build_db_with_incr.go index 3ef31844..ccfefe30 100644 --- a/pkg/snapshot/build_db_with_incr.go +++ b/pkg/snapshot/build_db_with_incr.go @@ -256,6 +256,17 @@ func BuildAccountsDbAuto( return nil, nil, err } + // Verify snapshot integrity by re-computing the LtHash from all accounts + // and comparing against the manifest. Detects tampered snapshots from + // untrusted RPC nodes before proceeding to replay. + // NOTE: We use the INCREMENTAL manifest's LtHash because the pebble index + // at this point contains accounts from BOTH full and incremental snapshots. + // The incremental manifest's LtHash reflects the final combined state. + if err := VerifySnapshotLtHash(accountsDb, incrementalManifest.LtHash); err != nil { + accountsDb.CloseDb() + return nil, nil, fmt.Errorf("snapshot verification failed: %w", err) + } + rpcClient := rpcclient.NewRpcClient(rpcEndpoints[0]) latestSlot, err := rpcClient.GetSlot() _, incrSlot = snapshotdl.ExtractIncrementalSnapshotSlots(incrementalSnapshotPath) diff --git a/pkg/snapshot/verify_lthash.go b/pkg/snapshot/verify_lthash.go new file mode 100644 index 00000000..3ac9adeb --- /dev/null +++ b/pkg/snapshot/verify_lthash.go @@ -0,0 +1,121 @@ +package snapshot + +import ( + "fmt" + "sync" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/lthash" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/cockroachdb/pebble" + "github.com/gagliardetto/solana-go" +) + +// VerifySnapshotLtHash re-computes the lattice hash from all accounts in the +// AccountsDB and compares it against the LtHash declared in the snapshot manifest. +// +// If they don't match, the snapshot may have been tampered with and should be +// discarded. This prevents a DoS vector where a malicious RPC node serves a +// snapshot with altered account data. +// +// The computation is parallelized: a single goroutine iterates the pebble index +// to collect pubkeys in batches, and a pool of workers reads the accounts and +// computes their individual LtHash contributions. Partial results are merged at +// the end. +func VerifySnapshotLtHash(accountsDb *accountsdb.AccountsDb, manifestLtHash *lthash.LtHash) error { + if manifestLtHash == nil { + mlog.Log.Warnf("Snapshot does not contain an LtHash — skipping verification (pre-LtHash snapshot)") + return nil + } + + mlog.Log.Infof("Verifying snapshot integrity: computing LtHash over all accounts...") + start := time.Now() + + // Iterate over all keys in the pebble index + iter, err := accountsDb.Index.NewIter(&pebble.IterOptions{}) + if err != nil { + return fmt.Errorf("creating pebble iterator: %w", err) + } + defer iter.Close() + + // Collect all pubkeys first so we can parallelize the hash computation. + // Each key in the index is a 32-byte Solana public key. + var allPubkeys []solana.PublicKey + + for iter.First(); iter.Valid(); iter.Next() { + key := iter.Key() + if len(key) != 32 { + continue // skip malformed entries + } + var pk solana.PublicKey + copy(pk[:], key) + allPubkeys = append(allPubkeys, pk) + } + if err := iter.Error(); err != nil { + return fmt.Errorf("iterating pebble index: %w", err) + } + + totalAccounts := len(allPubkeys) + if totalAccounts == 0 { + return fmt.Errorf("SNAPSHOT INTEGRITY FAILURE: pebble index contains 0 accounts — snapshot may be empty or corrupted") + } + mlog.Log.Infof("LtHash verification: hashing %d accounts...", totalAccounts) + + // Parallelize: split pubkeys into chunks, each worker computes a partial LtHash + numWorkers := 32 + if totalAccounts < numWorkers { + numWorkers = max(1, totalAccounts) + } + chunkSize := (totalAccounts + numWorkers - 1) / numWorkers + + partialHashes := make([]lthash.LtHash, numWorkers) + var wg sync.WaitGroup + + for w := range numWorkers { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + startIdx := workerID * chunkSize + endIdx := min(startIdx+chunkSize, totalAccounts) + + var partial lthash.LtHash + for i := startIdx; i < endIdx; i++ { + pk := allPubkeys[i] + acct, err := accountsDb.GetAccount(0, pk) + if err != nil { + continue // account may have been deleted (0 lamports) + } + if acct.Lamports == 0 { + continue // zero-lamport accounts don't contribute to LtHash + } + + var acctHash lthash.LtHash + acctHash.InitWithAcct(acct) + partial.MixIn(&acctHash) + } + partialHashes[workerID] = partial + }(w) + } + wg.Wait() + + // Merge all partial hashes + var computed lthash.LtHash + for i := range numWorkers { + computed.MixIn(&partialHashes[i]) + } + + elapsed := time.Since(start) + + if !computed.Equals(manifestLtHash) { + return fmt.Errorf( + "SNAPSHOT INTEGRITY FAILURE: computed LtHash does not match manifest LtHash "+ + "(%d accounts verified in %s). This snapshot may have been tampered with — "+ + "discard it and download from a different source", + totalAccounts, fmtDuration(elapsed), + ) + } + + mlog.Log.Infof("✓ Snapshot LtHash verified: %d accounts match manifest (%s)", totalAccounts, fmtDuration(elapsed)) + return nil +}