- internal/chain: end-to-end chain verification. Walks head → genesis,
verifies every cert (Ed25519 or STM as appropriate), and checks
continuity at every boundary:
epoch: same or +1 from previous
hash: current.previous_hash == previous.hash
AVK: same epoch → equal aggregate_verification_key
new epoch → matches previous.protocol_message.next_aggregate_verification_key
- cmd: 'verify chain' subcommand + 'verify manifest <dir>' for SHA-checking
downloaded immutable files
- internal/manifest: per-file SHA-256 verification against the digests.json
shipped in the snapshot's digests archive
- MCP: 8th tool 'mithril_verify_chain' for agent-driven full-chain verify
- README: complete rewrite — status table, architecture, gotchas, MCP
tool surface, exit code contract, build instructions
- LICENSE: Apache-2.0 (matches upstream Mithril)
Verified end to end against live networks:
preprod chain 90 certs (89 STM + 1 genesis) 1124 wins ✓
mainnet chain 89 certs (88 STM + 1 genesis) 210921 wins ✓
That's the wrap. Pure-Go consensus-correct Mithril client, single 10 MB
static binary, MCP-native, no CGo, no upstream Rust runtime.
139 lines
3.9 KiB
Go
139 lines
3.9 KiB
Go
// Package manifest verifies downloaded Mithril snapshot files against the
|
|
// per-immutable digest manifest extracted from the snapshot's `digests`
|
|
// archive.
|
|
//
|
|
// The manifest is a flat JSON array of {immutable_file_name, digest}
|
|
// entries — one per file in the chain DB's immutable directory. Each
|
|
// digest is the SHA-256 of the file contents.
|
|
package manifest
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Entry is one record from the digests JSON.
|
|
type Entry struct {
|
|
ImmutableFileName string `json:"immutable_file_name"`
|
|
Digest string `json:"digest"` // hex-encoded SHA-256
|
|
}
|
|
|
|
// Load reads + parses a digests.json file.
|
|
func Load(path string) ([]Entry, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
var out []Entry
|
|
if err := json.NewDecoder(f).Decode(&out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LocateDigests finds the digests.json file inside the extracted digests
|
|
// directory (named like `<network>-e<epoch>-i<imm>.digests.json`).
|
|
func LocateDigests(digestsDir string) (string, error) {
|
|
entries, err := os.ReadDir(digestsDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".digests.json") {
|
|
return filepath.Join(digestsDir, e.Name()), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no digests.json found under %s", digestsDir)
|
|
}
|
|
|
|
// Result captures the per-file outcome of a manifest verification pass.
|
|
type Result struct {
|
|
TotalEntries int `json:"total_entries"`
|
|
Verified int `json:"verified"`
|
|
Missing []string `json:"missing,omitempty"` // files in manifest, not on disk
|
|
Mismatched []string `json:"mismatched,omitempty"` // SHA didn't match
|
|
Unverifiable []string `json:"unverifiable,omitempty"` // failed to read
|
|
Extra []string `json:"extra,omitempty"` // files on disk, not in manifest
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// Verify walks the manifest and checks each immutable file under dbDir.
|
|
// Files in the immutable subdirectory are at `<dbDir>/immutable/<name>`.
|
|
func Verify(entries []Entry, dbDir string) (*Result, error) {
|
|
r := &Result{TotalEntries: len(entries)}
|
|
immDir := filepath.Join(dbDir, "immutable")
|
|
|
|
// Build set of expected names for the extra-file diff.
|
|
expected := make(map[string]struct{}, len(entries))
|
|
for _, e := range entries {
|
|
expected[e.ImmutableFileName] = struct{}{}
|
|
}
|
|
|
|
for _, e := range entries {
|
|
path := filepath.Join(immDir, e.ImmutableFileName)
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
r.Missing = append(r.Missing, e.ImmutableFileName)
|
|
continue
|
|
}
|
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
|
|
continue
|
|
}
|
|
if fi.IsDir() {
|
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": is a directory")
|
|
continue
|
|
}
|
|
got, err := sha256OfFile(path)
|
|
if err != nil {
|
|
r.Unverifiable = append(r.Unverifiable, e.ImmutableFileName+": "+err.Error())
|
|
continue
|
|
}
|
|
if got != e.Digest {
|
|
r.Mismatched = append(r.Mismatched, fmt.Sprintf("%s: got %s, want %s",
|
|
e.ImmutableFileName, got, e.Digest))
|
|
continue
|
|
}
|
|
r.Verified++
|
|
}
|
|
|
|
// Identify any extra files in immutable/ that aren't in the manifest.
|
|
if entries, err := os.ReadDir(immDir); err == nil {
|
|
for _, fe := range entries {
|
|
if fe.IsDir() {
|
|
continue
|
|
}
|
|
if _, ok := expected[fe.Name()]; !ok {
|
|
r.Extra = append(r.Extra, fe.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Strings(r.Missing)
|
|
sort.Strings(r.Mismatched)
|
|
sort.Strings(r.Unverifiable)
|
|
sort.Strings(r.Extra)
|
|
r.OK = len(r.Missing) == 0 && len(r.Mismatched) == 0 && len(r.Unverifiable) == 0
|
|
return r, nil
|
|
}
|
|
|
|
func sha256OfFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|