- 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.
309 lines
10 KiB
Go
309 lines
10 KiB
Go
// 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.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
|
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/stm"
|
|
"git.sulkta.coop/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
|
|
}
|