mithril-go/internal/chain/chain.go
Cobb Hayes 7b9b09133b Public-flip audit: module URL + README humanization
git.sulkta.coop → git.sulkta.com (matches the live public Forgejo endpoint).
README dropped AI-agent positioning + emoji status table; kept all
technical content (DST, MuSig aggregation, exit codes, MCP tool table).
2026-05-27 11:29:05 -07:00

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.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
}