wrap: chain verify + manifest verify + LICENSE + final docs
- 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.
This commit is contained in:
parent
5294cf0bfa
commit
12373af0b1
8 changed files with 905 additions and 90 deletions
309
internal/chain/chain.go
Normal file
309
internal/chain/chain.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// Package chain implements end-to-end Mithril certificate chain verification.
|
||||
//
|
||||
// Given a head certificate hash and a trusted genesis verification key,
|
||||
// Verify walks backwards through previous_hash until it reaches the
|
||||
// genesis certificate, checking at every step:
|
||||
//
|
||||
// 1. The cert itself is validly signed (Ed25519 for genesis, STM BLS for
|
||||
// standard certs — delegated to internal/verify and internal/stm).
|
||||
// 2. Epoch chaining: the previous cert's epoch is the same as, or exactly
|
||||
// one less than, the current cert's epoch.
|
||||
// 3. Hash chaining: current.previous_hash == previous.hash.
|
||||
// 4. AVK chaining: current.aggregate_verification_key equals either the
|
||||
// previous cert's aggregate_verification_key (same epoch) or the
|
||||
// previous cert's protocol_message.next_aggregate_verification_key
|
||||
// (epoch transition).
|
||||
//
|
||||
// Returns a ChainResult that documents every verified cert and any gaps.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.sulkta.com/Sulkta-Coop/mithril-go/internal/aggregator"
|
||||
"git.sulkta.com/Sulkta-Coop/mithril-go/internal/networks"
|
||||
"git.sulkta.com/Sulkta-Coop/mithril-go/internal/stm"
|
||||
"git.sulkta.com/Sulkta-Coop/mithril-go/internal/verify"
|
||||
)
|
||||
|
||||
// Step is the per-cert record in a chain-verification report.
|
||||
type Step struct {
|
||||
Hash string `json:"hash"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Kind string `json:"kind"` // "genesis" | "stm"
|
||||
Verified bool `json:"verified"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Signers int `json:"signers,omitempty"`
|
||||
TotalWins int `json:"total_wins,omitempty"`
|
||||
DistinctWins int `json:"distinct_wins,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the final chain-verification report.
|
||||
type Result struct {
|
||||
Network string `json:"network"`
|
||||
HeadHash string `json:"head_hash"`
|
||||
GenesisHash string `json:"genesis_hash,omitempty"`
|
||||
Length int `json:"length"`
|
||||
Verified bool `json:"verified"`
|
||||
FailureIndex int `json:"failure_index,omitempty"`
|
||||
FailureKind string `json:"failure_kind,omitempty"` // "cert" | "epoch" | "hash" | "avk"
|
||||
Error string `json:"error,omitempty"`
|
||||
Steps []Step `json:"steps"`
|
||||
}
|
||||
|
||||
// Verify walks the certificate chain from headHash back to the genesis cert,
|
||||
// verifying each cert and the continuity between adjacent certs.
|
||||
//
|
||||
// maxDepth caps the walk length (default 2048 — enough for years of certs).
|
||||
//
|
||||
// httpClient is used to fetch the raw certificate JSON which carries fields
|
||||
// (aggregate_verification_key, protocol_parameters) that the canonical
|
||||
// aggregator.Certificate type doesn't expose. Pass nil for http.DefaultClient.
|
||||
func Verify(ctx context.Context, httpClient *http.Client, network networks.Network, headHash string, maxDepth int) (*Result, error) {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 2048
|
||||
}
|
||||
client := aggregator.New(network.AggregatorURL)
|
||||
result := &Result{Network: network.Name, HeadHash: headHash}
|
||||
|
||||
// Walk chain head → genesis, collecting canonical + raw JSON for each.
|
||||
type entry struct {
|
||||
cert *aggregator.Certificate
|
||||
raw *rawCert
|
||||
}
|
||||
var entries []entry
|
||||
next := headHash
|
||||
for len(entries) < maxDepth {
|
||||
cert, err := client.GetCertificate(ctx, next)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("fetch cert %s: %w", next, err)
|
||||
}
|
||||
raw, err := fetchRaw(ctx, httpClient, network.AggregatorURL, next)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("fetch raw cert %s: %w", next, err)
|
||||
}
|
||||
entries = append(entries, entry{cert: cert, raw: raw})
|
||||
if cert.GenesisSignature != "" {
|
||||
result.GenesisHash = cert.Hash
|
||||
break
|
||||
}
|
||||
next = cert.PreviousHash
|
||||
if next == "" {
|
||||
return result, fmt.Errorf("chain broke at depth %d: no previous_hash", len(entries))
|
||||
}
|
||||
}
|
||||
result.Length = len(entries)
|
||||
if result.GenesisHash == "" {
|
||||
result.Error = fmt.Sprintf("exceeded max depth %d without hitting genesis", maxDepth)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Verify each cert. We walk earliest-first (genesis → head) so AVK
|
||||
// handoff validation is natural.
|
||||
//
|
||||
// Indexes are reversed from collection order: entries[0] = head, but
|
||||
// we want genesis first.
|
||||
vk, err := verify.DecodeGenesisVerifyKey(network.GenesisVerifyKey)
|
||||
if err != nil {
|
||||
result.Error = "decode genesis verify key: " + err.Error()
|
||||
return result, nil
|
||||
}
|
||||
result.Steps = make([]Step, len(entries))
|
||||
|
||||
for i := len(entries) - 1; i >= 0; i-- {
|
||||
idx := len(entries) - 1 - i // step index in result.Steps (0 = genesis)
|
||||
e := entries[i]
|
||||
step := Step{Hash: e.cert.Hash, Epoch: e.cert.Epoch}
|
||||
|
||||
if e.cert.GenesisSignature != "" {
|
||||
step.Kind = "genesis"
|
||||
err := verify.GenesisFromJSON(vk, e.cert.SignedMessage, e.cert.GenesisSignature, e.cert.ProtocolMessage)
|
||||
step.Verified = err == nil
|
||||
if err != nil {
|
||||
step.Error = err.Error()
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "cert"
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
} else {
|
||||
step.Kind = "stm"
|
||||
ms, err := stm.DecodeMultiSig(e.raw.MultiSignature)
|
||||
if err != nil {
|
||||
step.Error = "decode multi_signature: " + err.Error()
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "cert"
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
avk, err := stm.DecodeAVK(e.raw.AggregateVerificationKey)
|
||||
if err != nil {
|
||||
step.Error = "decode avk: " + err.Error()
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "cert"
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
params := stm.Parameters{
|
||||
K: e.raw.Metadata.Parameters.K,
|
||||
M: e.raw.Metadata.Parameters.M,
|
||||
PhiF: e.raw.Metadata.Parameters.PhiF,
|
||||
}
|
||||
step.Signers = len(ms.Signatures)
|
||||
step.TotalWins = ms.TotalWins()
|
||||
step.DistinctWins = len(ms.DistinctWins())
|
||||
if err := stm.Verify([]byte(e.cert.SignedMessage), ms, avk, params); err != nil {
|
||||
step.Error = err.Error()
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "cert"
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
step.Verified = true
|
||||
|
||||
// Continuity checks: this cert's AVK / previous_hash / epoch vs previous cert.
|
||||
if i+1 < len(entries) {
|
||||
prev := entries[i+1]
|
||||
// previous_hash chaining
|
||||
if e.cert.PreviousHash != prev.cert.Hash {
|
||||
step.Error = fmt.Sprintf("previous_hash mismatch: got %s, want %s",
|
||||
e.cert.PreviousHash, prev.cert.Hash)
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "hash"
|
||||
result.Error = step.Error
|
||||
return result, nil
|
||||
}
|
||||
// epoch chaining: allow same epoch or exactly one greater
|
||||
switch {
|
||||
case e.cert.Epoch == prev.cert.Epoch:
|
||||
// same epoch — AVK must match previous.aggregate_verification_key
|
||||
if !bytesEq(e.raw.AggregateVerificationKey, prev.raw.AggregateVerificationKey) {
|
||||
step.Error = "same-epoch AVK mismatch"
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "avk"
|
||||
result.Error = step.Error
|
||||
return result, nil
|
||||
}
|
||||
case e.cert.Epoch == prev.cert.Epoch+1:
|
||||
// new epoch — AVK must match previous.protocol_message.next_aggregate_verification_key
|
||||
prevNextAVK, err := extractNextAVK(prev.cert.ProtocolMessage)
|
||||
if err != nil {
|
||||
step.Error = "extract prev next_avk: " + err.Error()
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "avk"
|
||||
result.Error = step.Error
|
||||
return result, nil
|
||||
}
|
||||
if !avkHexMatches(e.raw.AggregateVerificationKey, prevNextAVK) {
|
||||
step.Error = "epoch-transition AVK does not match previous.next_aggregate_verification_key"
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "avk"
|
||||
result.Error = step.Error
|
||||
return result, nil
|
||||
}
|
||||
default:
|
||||
step.Error = fmt.Sprintf("epoch gap: %d → %d", prev.cert.Epoch, e.cert.Epoch)
|
||||
result.Steps[idx] = step
|
||||
result.FailureIndex = idx
|
||||
result.FailureKind = "epoch"
|
||||
result.Error = step.Error
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Steps[idx] = step
|
||||
}
|
||||
result.Verified = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// rawCert is the subset of the certificate JSON that chain verification
|
||||
// consumes beyond what aggregator.Certificate exposes.
|
||||
type rawCert struct {
|
||||
MultiSignature json.RawMessage `json:"multi_signature"`
|
||||
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
|
||||
Metadata struct {
|
||||
Parameters struct {
|
||||
K uint64 `json:"k"`
|
||||
M uint64 `json:"m"`
|
||||
PhiF float64 `json:"phi_f"`
|
||||
} `json:"parameters"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
func fetchRaw(ctx context.Context, client *http.Client, aggURL, hash string) (*rawCert, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, aggURL+"/certificate/"+hash, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("aggregator %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var r rawCert
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func bytesEq(a, b json.RawMessage) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extractNextAVK pulls the next_aggregate_verification_key string out of a
|
||||
// protocol_message JSON object.
|
||||
func extractNextAVK(pm json.RawMessage) (string, error) {
|
||||
var v struct {
|
||||
MessageParts map[string]string `json:"message_parts"`
|
||||
}
|
||||
if err := json.Unmarshal(pm, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
k, ok := v.MessageParts["next_aggregate_verification_key"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no next_aggregate_verification_key in protocol_message")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// avkHexMatches tests whether a raw JSON-string AVK value equals a given
|
||||
// hex string. Both are hex-of-ASCII-JSON; compare the inner hex strings.
|
||||
func avkHexMatches(rawJSON json.RawMessage, hexStr string) bool {
|
||||
if len(rawJSON) < 2 {
|
||||
return false
|
||||
}
|
||||
inner := string(rawJSON)
|
||||
if inner[0] == '"' && inner[len(inner)-1] == '"' {
|
||||
inner = inner[1 : len(inner)-1]
|
||||
}
|
||||
return inner == hexStr
|
||||
}
|
||||
139
internal/manifest/manifest.go
Normal file
139
internal/manifest/manifest.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// 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
|
||||
}
|
||||
Reference in a new issue