STM full verification landing — milestones C/D/E complete
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).
This commit is contained in:
parent
32f0057700
commit
920d7cf177
7 changed files with 647 additions and 16 deletions
12
README.md
12
README.md
|
|
@ -18,17 +18,19 @@ static binary with no runtime dependencies — useful for:
|
||||||
|
|
||||||
## Status
|
## 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 |
|
| Piece | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Aggregator REST client | ✅ list, get, cert, chain |
|
| 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) | ✅ |
|
| Resumable HTTP download (single stream, SHA hook) | ✅ |
|
||||||
| Streamed zstd+tar extract (tar-slip defended) | ✅ |
|
| Streamed zstd+tar extract (tar-slip defended) | ✅ |
|
||||||
| `download` — digests + ancillary | ✅ (immutables loop pending) |
|
| `download` — digests + ancillary | ✅ (full immutables loop pending) |
|
||||||
| Genesis Ed25519 verification | ⚠️ stubbed, needs signed_message derivation wired |
|
| **Genesis Ed25519 verification** | ✅ live mainnet + preprod |
|
||||||
| STM BLS12-381 aggregate verification | ❌ the sprint — see below |
|
| **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
|
## Usage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -26,6 +28,7 @@ import (
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact"
|
"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/mcp"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
"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"
|
"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 != "" {
|
if cert.GenesisSignature != "" {
|
||||||
return verifyGenesisCert(n, cert, asJSON)
|
return verifyGenesisCert(n, cert, asJSON)
|
||||||
}
|
}
|
||||||
// STM cert
|
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
|
||||||
|
}
|
||||||
|
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 {
|
if asJSON {
|
||||||
return emitJSON(map[string]any{
|
out := map[string]any{
|
||||||
"cert_hash": cert.Hash,
|
"cert_hash": cert.Hash,
|
||||||
"kind": "stm",
|
"kind": "stm",
|
||||||
"verified": false,
|
"epoch": cert.Epoch,
|
||||||
"error": "STM BLS verification not yet implemented",
|
"signed_message": cert.SignedMessage,
|
||||||
})
|
"signers": len(ms.Signatures),
|
||||||
|
"total_wins": ms.TotalWins(),
|
||||||
|
"distinct_wins": len(ms.DistinctWins()),
|
||||||
|
"params": params,
|
||||||
|
"verified": verr == nil,
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "cert is STM-signed; STM verification not yet implemented")
|
if verr != nil {
|
||||||
return exitGeneric
|
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 {
|
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,
|
Version: version,
|
||||||
})
|
})
|
||||||
registerMCPTools(s)
|
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 := s.Run(ctx); err != nil {
|
||||||
if err == context.Canceled || err == context.DeadlineExceeded {
|
if err == context.Canceled || err == context.DeadlineExceeded {
|
||||||
return exitCanceled
|
return exitCanceled
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,17 @@ import (
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
|
"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/mcp"
|
||||||
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
|
"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"
|
"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
|
// networkArgOrDefault pulls a "network" string from the args map, defaulting
|
||||||
// to "preprod" if absent. Returns the resolved network + client.
|
// to "preprod" if absent. Returns the resolved network + client.
|
||||||
func networkArgOrDefault(args map[string]any) (networks.Network, *aggregator.Client, error) {
|
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{
|
s.RegisterTool(mcp.Tool{
|
||||||
Name: "mithril_verify_genesis",
|
Name: "mithril_verify_genesis",
|
||||||
Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " +
|
Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " +
|
||||||
|
|
|
||||||
120
internal/stm/lottery.go
Normal file
120
internal/stm/lottery.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
162
internal/stm/merkle.go
Normal file
162
internal/stm/merkle.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
97
internal/stm/verify.go
Normal file
97
internal/stm/verify.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
72
internal/stm/verify_live_test.go
Normal file
72
internal/stm/verify_live_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue