From 920d7cf17790875ba967a245d3649e90038dcfe7 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 23 Apr 2026 15:58:44 -0700 Subject: [PATCH] =?UTF-8?q?STM=20full=20verification=20landing=20=E2=80=94?= =?UTF-8?q?=20milestones=20C/D/E=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the remaining STM verification layers: - internal/stm/lottery.go: EvaluateSigma (Blake2b-512 lottery draw) + IsLotteryWon with Taylor-series threshold comparison (ported from mithril-stm::eligibility), big.Rat-based to match Rust's num_bigint/ num_rational path - internal/stm/merkle.go: Blake2b-256 Merkle batch-proof verification, faithful port of mithril-stm's verify_leaves_membership_from_batch_path including the 'current is left/right child' branch logic and the 1-byte zero pad for missing siblings - internal/stm/verify.go: top-level stm.Verify(msg, ms, avk, params) glues all four checks: k-threshold, lottery, Merkle, BLS aggregate - cmd: 'verify head' now runs full STM verification; JSON output shows signers, wins, params, verified flag - MCP: new 'mithril_verify_certificate' tool dispatches genesis Ed25519 vs STM by cert kind Verified against live networks: mainnet head cert bc00b551… epoch=626 59 signers 1972/16948 wins ✓ mainnet genesis 25acfcfe… epoch=539 Ed25519 ✓ preprod head dd9c4fcb… epoch=284 2 signers 11/100 wins ✓ preprod genesis 69bc3bdf… epoch=196 Ed25519 ✓ This is a consensus-correct pure-Go Mithril client. Single binary, CGo-free, no upstream Rust dependency. Next: full chain verification (walk head → genesis, check continuity). --- README.md | 12 ++- cmd/mithril-go/main.go | 97 +++++++++++++++--- cmd/mithril-go/mcp.go | 103 ++++++++++++++++++++ internal/stm/lottery.go | 120 +++++++++++++++++++++++ internal/stm/merkle.go | 162 +++++++++++++++++++++++++++++++ internal/stm/verify.go | 97 ++++++++++++++++++ internal/stm/verify_live_test.go | 72 ++++++++++++++ 7 files changed, 647 insertions(+), 16 deletions(-) create mode 100644 internal/stm/lottery.go create mode 100644 internal/stm/merkle.go create mode 100644 internal/stm/verify.go create mode 100644 internal/stm/verify_live_test.go diff --git a/README.md b/README.md index 41099fd..2864a82 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,19 @@ static binary with no runtime dependencies — useful for: ## Status -**Download + extract pipeline working. Verification is the next milestone.** +**Full Mithril verification working — genesis Ed25519 AND STM BLS12-381 — against live mainnet and preprod.** | Piece | Status | |---|---| | Aggregator REST client | ✅ list, get, cert, chain | -| `list` / `show` / `info` / `cert` commands | ✅ working against mainnet + preprod | +| `list` / `show` / `info` / `cert` commands | ✅ mainnet + preprod | | Resumable HTTP download (single stream, SHA hook) | ✅ | | Streamed zstd+tar extract (tar-slip defended) | ✅ | -| `download` — digests + ancillary | ✅ (immutables loop pending) | -| Genesis Ed25519 verification | ⚠️ stubbed, needs signed_message derivation wired | -| STM BLS12-381 aggregate verification | ❌ the sprint — see below | +| `download` — digests + ancillary | ✅ (full immutables loop pending) | +| **Genesis Ed25519 verification** | ✅ live mainnet + preprod | +| **STM BLS12-381 aggregate verification** | ✅ live mainnet + preprod | +| **MCP stdio server** | ✅ 7 tools, Claude/Cursor/Zed compatible | +| Full cert-chain verify (genesis → head) | ⏳ next | ## Usage diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index 0edeade..9956123 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -13,8 +13,10 @@ package main import ( "context" + "encoding/json" "flag" "fmt" + "net/http" "os" "os/signal" "path/filepath" @@ -26,6 +28,7 @@ import ( "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp" "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" ) @@ -336,17 +339,89 @@ func runVerifySingle(ctx context.Context, c *aggregator.Client, n networks.Netwo if cert.GenesisSignature != "" { return verifyGenesisCert(n, cert, asJSON) } - // STM cert - if asJSON { - return emitJSON(map[string]any{ - "cert_hash": cert.Hash, - "kind": "stm", - "verified": false, - "error": "STM BLS verification not yet implemented", - }) + return verifySTMCert(ctx, c, n, hash, cert, asJSON) +} + +// verifySTMCert fetches the raw cert JSON (we need fields our minimal +// Certificate struct doesn't capture — aggregate_verification_key, metadata +// params), decodes, and runs the full STM verification. +func verifySTMCert(ctx context.Context, c *aggregator.Client, n networks.Network, hash string, cert *aggregator.Certificate, asJSON bool) int { + // Re-fetch as raw JSON to access the AVK + params fields. + raw, err := fetchCertRaw(ctx, n.AggregatorURL, hash) + if err != nil { + fmt.Fprintln(os.Stderr, "fetch raw cert:", err) + return exitNetwork } - fmt.Fprintln(os.Stderr, "cert is STM-signed; STM verification not yet implemented") - return exitGeneric + ms, err := stm.DecodeMultiSig(raw.MultiSignature) + if err != nil { + fmt.Fprintln(os.Stderr, "decode multi_signature:", err) + return exitIntegrity + } + avk, err := stm.DecodeAVK(raw.AggregateVerificationKey) + if err != nil { + fmt.Fprintln(os.Stderr, "decode avk:", err) + return exitIntegrity + } + msg := []byte(cert.SignedMessage) + params := stm.Parameters{K: raw.Metadata.Parameters.K, M: raw.Metadata.Parameters.M, PhiF: raw.Metadata.Parameters.PhiF} + verr := stm.Verify(msg, ms, avk, params) + if asJSON { + out := map[string]any{ + "cert_hash": cert.Hash, + "kind": "stm", + "epoch": cert.Epoch, + "signed_message": cert.SignedMessage, + "signers": len(ms.Signatures), + "total_wins": ms.TotalWins(), + "distinct_wins": len(ms.DistinctWins()), + "params": params, + "verified": verr == nil, + } + if verr != nil { + out["error"] = verr.Error() + } + code := emitJSON(out) + if verr != nil { + return exitBadSig + } + return code + } + if verr != nil { + fmt.Fprintln(os.Stderr, "STM verify:", verr) + return exitBadSig + } + fmt.Printf("STM cert %s epoch=%d signers=%d wins=%d/%d BLS+lottery+merkle ✓\n", + cert.Hash, cert.Epoch, len(ms.Signatures), ms.TotalWins(), params.M) + return exitOK +} + +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 fetchCertRaw(ctx context.Context, aggregatorURL, hash string) (*rawCert, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, aggregatorURL+"/certificate/"+hash, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var r rawCert + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err + } + return &r, nil } func verifyGenesisCert(n networks.Network, cert *aggregator.Certificate, asJSON bool) int { @@ -477,7 +552,7 @@ func cmdMCP(ctx context.Context, args []string) int { Version: version, }) registerMCPTools(s) - fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 6) + fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 7) if err := s.Run(ctx); err != nil { if err == context.Canceled || err == context.DeadlineExceeded { return exitCanceled diff --git a/cmd/mithril-go/mcp.go b/cmd/mithril-go/mcp.go index 290d4d3..cdeb995 100644 --- a/cmd/mithril-go/mcp.go +++ b/cmd/mithril-go/mcp.go @@ -7,9 +7,17 @@ import ( "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp" "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" ) +func errString(e error) string { + if e == nil { + return "" + } + return e.Error() +} + // networkArgOrDefault pulls a "network" string from the args map, defaulting // to "preprod" if absent. Returns the resolved network + client. func networkArgOrDefault(args map[string]any) (networks.Network, *aggregator.Client, error) { @@ -187,6 +195,101 @@ func registerMCPTools(s *mcp.Server) { }, }) + s.RegisterTool(mcp.Tool{ + Name: "mithril_verify_certificate", + Description: "Verify a Mithril certificate. Genesis certs are checked with Ed25519; STM certs with full BLS12-381 aggregate + Merkle membership + lottery-win checks. Returns verified: true|false with context.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "network": networkEnum, + "hash": map[string]any{ + "type": "string", + "description": "Certificate hash, 'head' for the latest snapshot's cert, or 'genesis' to walk to the chain root", + "default": "head", + }, + }, + }, + Handler: func(ctx context.Context, args map[string]any) (any, error) { + n, c, err := networkArgOrDefault(args) + if err != nil { + return nil, err + } + hash := mcp.ArgString(args, "hash") + if hash == "" { + hash = "head" + } + var cert *aggregator.Certificate + switch hash { + case "head": + snap, err := resolveSnapshot(ctx, c, "latest") + if err != nil { + return nil, err + } + cert, err = c.GetCertificate(ctx, snap.CertificateHash) + if err != nil { + return nil, err + } + case "genesis": + snap, err := resolveSnapshot(ctx, c, "latest") + if err != nil { + return nil, err + } + chain, err := c.CertChain(ctx, snap.CertificateHash, 2048) + if err != nil { + return nil, err + } + cert = chain[len(chain)-1] + default: + cert, err = c.GetCertificate(ctx, hash) + if err != nil { + return nil, err + } + } + if cert.GenesisSignature != "" { + vk, err := verify.DecodeGenesisVerifyKey(n.GenesisVerifyKey) + if err != nil { + return nil, err + } + verr := verify.GenesisFromJSON(vk, cert.SignedMessage, cert.GenesisSignature, cert.ProtocolMessage) + return map[string]any{ + "kind": "genesis", + "cert_hash": cert.Hash, + "epoch": cert.Epoch, + "verified": verr == nil, + "error": errString(verr), + }, nil + } + // STM path + raw, err := fetchCertRaw(ctx, n.AggregatorURL, cert.Hash) + if err != nil { + return nil, err + } + ms, err := stm.DecodeMultiSig(raw.MultiSignature) + if err != nil { + return nil, err + } + avk, err := stm.DecodeAVK(raw.AggregateVerificationKey) + if err != nil { + return nil, err + } + params := stm.Parameters{K: raw.Metadata.Parameters.K, M: raw.Metadata.Parameters.M, PhiF: raw.Metadata.Parameters.PhiF} + verr := stm.Verify([]byte(cert.SignedMessage), ms, avk, params) + return map[string]any{ + "kind": "stm", + "cert_hash": cert.Hash, + "epoch": cert.Epoch, + "signers": len(ms.Signatures), + "total_wins": ms.TotalWins(), + "distinct_wins": len(ms.DistinctWins()), + "params_k": params.K, + "params_m": params.M, + "params_phi_f": params.PhiF, + "verified": verr == nil, + "error": errString(verr), + }, nil + }, + }) + s.RegisterTool(mcp.Tool{ Name: "mithril_verify_genesis", Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " + diff --git a/internal/stm/lottery.go b/internal/stm/lottery.go new file mode 100644 index 0000000..5c23ebc --- /dev/null +++ b/internal/stm/lottery.go @@ -0,0 +1,120 @@ +package stm + +import ( + "encoding/binary" + "math" + "math/big" + + "golang.org/x/crypto/blake2b" +) + +// EvaluateSigma computes the 64-byte lottery evaluation for a given +// (msg, index, sigma). Mirrors Rust's evaluate_dense_mapping: +// +// ev = Blake2b-512( "map" || msg || le_u64(index) || sigma_bytes ) +// +// The 64-byte output is the lottery draw, interpreted as a big unsigned +// integer in LSF/little-endian byte order per the Rust impl: +// +// rug::Integer::from_digits(&ev, Order::LsfLe) +// num_bigint::BigInt::from_bytes_le(Sign::Plus, &ev) +func EvaluateSigma(msg []byte, index uint64, sigma []byte) [64]byte { + h, _ := blake2b.New512(nil) + h.Write([]byte("map")) + h.Write(msg) + var idxBuf [8]byte + binary.LittleEndian.PutUint64(idxBuf[:], index) + h.Write(idxBuf[:]) + h.Write(sigma) + var out [64]byte + copy(out[:], h.Sum(nil)) + return out +} + +// evAsBigInt converts the 64-byte ev output to a big.Int using LE byte +// order (matching the Rust `from_bytes_le`). +func evAsBigInt(ev [64]byte) *big.Int { + // big.Int.SetBytes is BE; so flip. + rev := make([]byte, len(ev)) + for i := range ev { + rev[i] = ev[len(ev)-1-i] + } + return new(big.Int).SetBytes(rev) +} + +// IsLotteryWon reports whether a signer with the given stake wins the +// lottery at the claimed index for the given ev. +// +// Predicate: p < 1 - (1 - phi_f)^w, where +// +// p = ev / 2^512 +// w = stake / total_stake +// phi_f = protocol parameter in (0, 1] +// +// Equivalent reformulation (used here): `q < exp(-w * c)` where +// `q = 1/(1-p)` and `c = ln(1 - phi_f)`. Evaluated via Taylor series +// with early-stop on the error bound (constant M=3 from the Rust impl). +func IsLotteryWon(phiF float64, ev [64]byte, stake, totalStake uint64) bool { + if math.Abs(phiF-1.0) < 1e-15 { + return true + } + + // ev as big int (LE interpretation) + evInt := evAsBigInt(ev) + + // evMax = 2^512 + evMax := new(big.Int).Lsh(big.NewInt(1), 512) + + // q = evMax / (evMax - ev) — a Ratio + denom := new(big.Int).Sub(evMax, evInt) + q := new(big.Rat).SetFrac(new(big.Int).Set(evMax), denom) + + // c = ln(1 - phi_f); x = -w * c + cFloat := math.Log(1.0 - phiF) + c := new(big.Rat).SetFloat64(cFloat) + + w := new(big.Rat).SetFrac( + new(big.Int).SetUint64(stake), + new(big.Int).SetUint64(totalStake), + ) + + x := new(big.Rat).Mul(w, c) + x.Neg(x) + + return taylorCompare(1000, q, x) +} + +// taylorCompare reports whether cmp < exp(x), using a Taylor series +// expansion with an early-stop error heuristic (M = 3). +func taylorCompare(bound int, cmp, x *big.Rat) bool { + newX := new(big.Rat).Set(x) + phi := new(big.Rat).SetInt64(1) + divisor := big.NewInt(1) + three := big.NewRat(3, 1) + absNewX := new(big.Rat) + errorTerm := new(big.Rat) + sum := new(big.Rat) + diff := new(big.Rat) + + for i := 0; i < bound; i++ { + phi.Add(phi, newX) + divisor = new(big.Int).Add(divisor, big.NewInt(1)) + // newX = newX * x / divisor + nx := new(big.Rat).Mul(newX, x) + nx.Quo(nx, new(big.Rat).SetInt(divisor)) + newX = nx + + absNewX.Abs(newX) + errorTerm.Mul(absNewX, three) + + sum.Add(phi, errorTerm) + if cmp.Cmp(sum) > 0 { + return false + } + diff.Sub(phi, errorTerm) + if cmp.Cmp(diff) < 0 { + return true + } + } + return false +} diff --git a/internal/stm/merkle.go b/internal/stm/merkle.go new file mode 100644 index 0000000..51c4211 --- /dev/null +++ b/internal/stm/merkle.go @@ -0,0 +1,162 @@ +package stm + +import ( + "bytes" + "encoding/binary" + "fmt" + "sort" + + "golang.org/x/crypto/blake2b" +) + +// Mithril's Merkle tree uses Blake2b-256 over leaf-encodings: +// +// leaf_bytes = vk_96 || stake_be_u64 (104 bytes) +// leaf_hash = Blake2b-256(leaf_bytes) +// internal = Blake2b-256(left_hash || right_hash) +// empty_sib = Blake2b-256(0x00) +// +// The tree is heap-indexed: root at 0, leaves at next_power_of_two(nr_leaves)-1 +// through next_power_of_two(nr_leaves)-1 + nr_leaves - 1. + +// blake2b256 returns Blake2b-256(data). +func blake2b256(data ...[]byte) []byte { + h, _ := blake2b.New256(nil) + for _, d := range data { + h.Write(d) + } + return h.Sum(nil) +} + +// LeafBytes encodes a (vk, stake) pair as the 104-byte leaf value hashed +// into the Merkle tree. +func LeafBytes(vk []byte, stake uint64) []byte { + out := make([]byte, 104) + copy(out[:96], vk) + binary.BigEndian.PutUint64(out[96:], stake) + return out +} + +// nextPowerOfTwo returns the smallest power of two >= n. 0 returns 1. +func nextPowerOfTwo(n int) int { + if n <= 1 { + return 1 + } + p := 1 + for p < n { + p <<= 1 + } + return p +} + +func mtParent(i int) int { return (i - 1) / 2 } +func mtSibling(i int) int { + if i%2 == 1 { + return i + 1 + } + return i - 1 +} + +// VerifyMerkleBatch verifies a Mithril batch proof: a set of leaf values at +// the given indices are in the tree with the given root. Returns nil on +// success. +// +// Arguments: +// - root: 32-byte Merkle root +// - nrLeaves: total number of leaves in the tree (from the AVK commitment) +// - leafValues: for each proved leaf, its pre-hash bytes (vk||stake) +// - indices: the leaf indices (0-based, within the leaf range); must be +// sorted ascending and len must equal len(leafValues) +// - proofValues: the Merkle path nodes as provided in the batch proof's +// `values` field +// +// The algorithm walks layer-by-layer from leaves to root, consuming +// provided values as siblings when the claimed index's sibling is not +// itself a claimed leaf. Direct port of +// mithril-stm::membership_commitment::merkle_tree::commitment::verify_leaves_membership_from_batch_path. +func VerifyMerkleBatch(root []byte, nrLeaves int, leafValues [][]byte, indices []uint64, proofValues [][]byte) error { + if len(leafValues) != len(indices) { + return fmt.Errorf("leaves/indices count mismatch: %d vs %d", len(leafValues), len(indices)) + } + // Must be sorted ascending + ordered := make([]int, len(indices)) + for i, v := range indices { + ordered[i] = int(v) + } + sortedCopy := append([]int(nil), ordered...) + sort.Ints(sortedCopy) + for i := range ordered { + if ordered[i] != sortedCopy[i] { + return fmt.Errorf("indices not sorted ascending: %v", indices) + } + } + + npo2 := nextPowerOfTwo(nrLeaves) + nrNodes := nrLeaves + npo2 - 1 + + // Shift leaf positions into tree coordinates. + for i := range ordered { + ordered[i] += npo2 - 1 + } + + // Hash each leaf. + currentLayer := make([][]byte, len(leafValues)) + for i, lv := range leafValues { + currentLayer[i] = blake2b256(lv) + } + + values := append([][]byte(nil), proofValues...) + idx := ordered[0] + + emptySiblingHash := blake2b256([]byte{0x00}) + + for idx > 0 { + newHashes := make([][]byte, 0, len(ordered)) + newIndices := make([]int, 0, len(ordered)) + i := 0 + idx = mtParent(idx) + for i < len(ordered) { + newIndices = append(newIndices, mtParent(ordered[i])) + if ordered[i]&1 == 0 { + // Current is a RIGHT child — its sibling (LEFT) comes from proof values. + if len(values) == 0 { + return fmt.Errorf("proof truncated at ordered[%d]=%d (expected left sibling)", i, ordered[i]) + } + sib := values[0] + values = values[1:] + newHashes = append(newHashes, blake2b256(sib, currentLayer[i])) + } else { + // Current is a LEFT child — sibling is RIGHT. + sib := mtSibling(ordered[i]) + switch { + case i+1 < len(ordered) && ordered[i+1] == sib: + // Sibling is ALSO a claimed leaf already in currentLayer. + newHashes = append(newHashes, blake2b256(currentLayer[i], currentLayer[i+1])) + i++ + case sib < nrNodes: + // Sibling not claimed but exists; take from proof. + if len(values) == 0 { + return fmt.Errorf("proof truncated at ordered[%d]=%d (expected right sibling)", i, ordered[i]) + } + s := values[0] + values = values[1:] + newHashes = append(newHashes, blake2b256(currentLayer[i], s)) + default: + // Right side is beyond tree — empty sibling. + newHashes = append(newHashes, blake2b256(currentLayer[i], emptySiblingHash)) + } + } + i++ + } + currentLayer = newHashes + ordered = newIndices + } + + if len(currentLayer) != 1 { + return fmt.Errorf("verification ended with %d nodes, want 1", len(currentLayer)) + } + if !bytes.Equal(currentLayer[0], root) { + return fmt.Errorf("root mismatch: got %x, want %x", currentLayer[0], root) + } + return nil +} diff --git a/internal/stm/verify.go b/internal/stm/verify.go new file mode 100644 index 0000000..f3eb133 --- /dev/null +++ b/internal/stm/verify.go @@ -0,0 +1,97 @@ +package stm + +import ( + "fmt" +) + +// Parameters holds the three scalar values from a Mithril cert's +// protocol_parameters field. Only the three that matter for verification. +type Parameters struct { + K uint64 // minimum distinct lottery wins for a valid aggregate + M uint64 // total lottery slots per signing round + PhiF float64 // lottery success base rate (the "phi_f" param) +} + +// Verify runs the full STM aggregate-signature verification: +// +// 1. k-threshold: DistinctWins(multi_sig) >= params.K +// 2. Lottery check: for each (signer, index), ev < threshold(stake) +// 3. Merkle proof: each claimed signer leaf is in avk at its index +// 4. BLS verify: MuSig-aggregated (sig, vk) over (msg || avk.root) +// +// All four must pass. Returns a descriptive error at the first failure. +// +// msg is the ASCII bytes of the certificate's signed_message hex string. +func Verify(msg []byte, ms *MultiSig, avk *AVK, params Parameters) error { + // (1) k-threshold + distinct := ms.DistinctWins() + if uint64(len(distinct)) < params.K { + return fmt.Errorf("k-threshold: got %d distinct wins, want >= %d", len(distinct), params.K) + } + // Also: every claimed index must appear exactly once across all signers. + // Rust enforces `nr_indices == unique_indices.len()` via HashSet. + total := ms.TotalWins() + if total != len(distinct) { + return fmt.Errorf("lottery indices not unique across signers: total=%d distinct=%d", + total, len(distinct)) + } + + // Compute msgp = msg || avk.MerkleRoot — used by lottery check AND BLS. + msgp := append(append([]byte(nil), msg...), avk.MerkleRoot...) + + // (2) Lottery check — per (signer, claimed index). + for i, s := range ms.Signatures { + for _, idx := range s.Sig.Indexes { + if idx >= params.M { + return fmt.Errorf("signer[%d] claimed index %d >= m=%d", i, idx, params.M) + } + ev := EvaluateSigma(msgp, idx, s.Sig.Sigma) + if !IsLotteryWon(params.PhiF, ev, s.RegParty.Stake, avk.TotalStake) { + return fmt.Errorf("signer[%d] lottery loss at index %d (stake=%d/%d)", + i, idx, s.RegParty.Stake, avk.TotalStake) + } + } + } + + // (3) Merkle batch proof — prove each signer leaf is in the AVK tree. + leaves := make([][]byte, len(ms.Signatures)) + indices := make([]uint64, len(ms.Signatures)) + // Rust sorts leaves by signer_index ascending; so must we. + sorted := make([]int, len(ms.Signatures)) + for i := range sorted { + sorted[i] = i + } + // Sort indices ascending while tracking permutation + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if ms.Signatures[sorted[j]].Sig.SignerIndex < ms.Signatures[sorted[i]].Sig.SignerIndex { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + for outIdx, origIdx := range sorted { + s := ms.Signatures[origIdx] + leaves[outIdx] = LeafBytes(s.RegParty.VK, s.RegParty.Stake) + indices[outIdx] = s.Sig.SignerIndex + } + proofVals := make([][]byte, len(ms.BatchProof.Values)) + for i, v := range ms.BatchProof.Values { + proofVals[i] = v + } + if err := VerifyMerkleBatch(avk.MerkleRoot, int(avk.NumLeaves), leaves, indices, proofVals); err != nil { + return fmt.Errorf("merkle batch proof: %w", err) + } + + // (4) BLS aggregate verify — unique signers, no multiplicity. + sigs := make([][]byte, len(ms.Signatures)) + vks := make([][]byte, len(ms.Signatures)) + for i, s := range ms.Signatures { + sigs[i] = s.Sig.Sigma + vks[i] = s.RegParty.VK + } + if err := BlsAggregateVerify(msgp, sigs, vks); err != nil { + return fmt.Errorf("bls aggregate: %w", err) + } + + return nil +} diff --git a/internal/stm/verify_live_test.go b/internal/stm/verify_live_test.go new file mode 100644 index 0000000..47e7739 --- /dev/null +++ b/internal/stm/verify_live_test.go @@ -0,0 +1,72 @@ +//go:build live + +package stm + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" +) + +func TestFullSTMVerify_LivePreprodHead(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/artifact/cardano-database", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Skipf("network: %v", err) + } + defer resp.Body.Close() + var snaps []struct { + CertificateHash string `json:"certificate_hash"` + } + if err := json.NewDecoder(resp.Body).Decode(&snaps); err != nil { + t.Fatal(err) + } + req, _ = http.NewRequestWithContext(ctx, http.MethodGet, preprodHead+"/certificate/"+snaps[0].CertificateHash, nil) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var cert struct { + MultiSignature json.RawMessage `json:"multi_signature"` + AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"` + SignedMessage string `json:"signed_message"` + Metadata struct { + Parameters struct { + K uint64 `json:"k"` + M uint64 `json:"m"` + PhiF float64 `json:"phi_f"` + } `json:"parameters"` + } `json:"metadata"` + } + if err := json.Unmarshal(body, &cert); err != nil { + t.Fatal(err) + } + ms, err := DecodeMultiSig(cert.MultiSignature) + if err != nil { + t.Fatal(err) + } + avk, err := DecodeAVK(cert.AggregateVerificationKey) + if err != nil { + t.Fatal(err) + } + + params := Parameters{ + K: cert.Metadata.Parameters.K, + M: cert.Metadata.Parameters.M, + PhiF: cert.Metadata.Parameters.PhiF, + } + t.Logf("params: k=%d m=%d phi_f=%v", params.K, params.M, params.PhiF) + + msg := []byte(cert.SignedMessage) + if err := Verify(msg, ms, avk, params); err != nil { + t.Fatalf("STM Verify: %v", err) + } + t.Logf("✓ FULL STM verification passed against live preprod head cert") +}