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