From 97a94341069ebf2703b87bbc2bb59a70a62e02e4 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 23 Apr 2026 15:33:41 -0700 Subject: [PATCH] =?UTF-8?q?genesis=20Ed25519=20verification=20=E2=80=94=20?= =?UTF-8?q?working=20against=20live=20mainnet=20+=20preprod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify package: ComputeProtocolMessageHash mirrors Rust's BTreeMap-ordered SHA256 over key||value concatenation (enum declaration order, not alpha) - DecodeGenesisVerifyKey unpacks Mithril's 'hex of ASCII [b0,b1,...,b31]' wrapping convention; also accepts plain 64-char hex - Genesis() verifies the Ed25519 signature over the ASCII BYTES of the signed_message HEX string (critical subtlety from upstream) - networks: real genesis vkeys for mainnet + preprod + preview from mithril-infra/configuration/*/genesis.vkey - cmd: 'verify genesis' walks head→genesis chain, verifies the terminal cert; 'verify head' and 'verify ' also wired; JSON output supported - exit codes honored: 3 network, 4 integrity, 5 bad sig verified: mainnet genesis cert 25acfcfe… epoch 539 Ed25519 ✓ preprod genesis cert 69bc3bdf… epoch 196 Ed25519 ✓ next: STM BLS12-381 aggregate verification (the big one) --- cmd/mithril-go/main.go | 113 +++++++++++++++++++- internal/networks/networks.go | 5 +- internal/verify/verify.go | 189 ++++++++++++++++++++++++++++------ 3 files changed, 273 insertions(+), 34 deletions(-) diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index aa78c30..024b340 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -25,6 +25,7 @@ import ( "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks" + "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify" ) const version = "0.0.3-dev" @@ -264,8 +265,116 @@ func cmdDownload(ctx context.Context, args []string) int { } func cmdVerify(ctx context.Context, args []string) int { - fmt.Fprintln(os.Stderr, "verify: not yet implemented (STM BLS sprint pending)") - return 1 + fs := flag.NewFlagSet("verify", flag.ExitOnError) + asJSON := fs.Bool("json", false, "emit structured JSON") + n, rest, err := resolveNetwork(fs, args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return exitUsage + } + if len(rest) == 0 { + fmt.Fprintln(os.Stderr, "verify: cert hash required (or 'head' / 'genesis')") + return exitUsage + } + mode := rest[0] // "head" = verify head cert (STM, not yet), "genesis" = walk chain + verify genesis, or a specific hash + c := aggregator.New(n.AggregatorURL) + + switch mode { + case "genesis": + return runVerifyGenesis(ctx, c, n, *asJSON) + case "head": + return runVerifyHead(ctx, c, n, *asJSON) + default: + // Treat as a literal cert hash: fetch + verify + return runVerifySingle(ctx, c, n, mode, *asJSON) + } +} + +func runVerifyGenesis(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int { + // Find the head snapshot's cert, walk to genesis, verify Ed25519 on the genesis cert. + snap, err := resolveSnapshot(ctx, c, "latest") + if err != nil { + fmt.Fprintln(os.Stderr, "resolve:", err) + return exitNetwork + } + chain, err := c.CertChain(ctx, snap.CertificateHash, 2048) + if err != nil { + fmt.Fprintln(os.Stderr, "chain:", err) + return exitNetwork + } + if len(chain) == 0 { + fmt.Fprintln(os.Stderr, "empty chain") + return exitGeneric + } + gen := chain[len(chain)-1] + if gen.GenesisSignature == "" { + fmt.Fprintln(os.Stderr, "tail of chain is not a genesis certificate") + return exitGeneric + } + return verifyGenesisCert(n, gen, asJSON) +} + +func runVerifyHead(ctx context.Context, c *aggregator.Client, n networks.Network, asJSON bool) int { + snap, err := resolveSnapshot(ctx, c, "latest") + if err != nil { + fmt.Fprintln(os.Stderr, "resolve:", err) + return exitNetwork + } + return runVerifySingle(ctx, c, n, snap.CertificateHash, asJSON) +} + +func runVerifySingle(ctx context.Context, c *aggregator.Client, n networks.Network, hash string, asJSON bool) int { + cert, err := c.GetCertificate(ctx, hash) + if err != nil { + fmt.Fprintln(os.Stderr, "cert:", err) + return exitNetwork + } + 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", + }) + } + fmt.Fprintln(os.Stderr, "cert is STM-signed; STM verification not yet implemented") + return exitGeneric +} + +func verifyGenesisCert(n networks.Network, cert *aggregator.Certificate, asJSON bool) int { + vk, err := verify.DecodeGenesisVerifyKey(n.GenesisVerifyKey) + if err != nil { + fmt.Fprintln(os.Stderr, "decode genesis key:", err) + return exitGeneric + } + err = verify.GenesisFromJSON(vk, cert.SignedMessage, cert.GenesisSignature, cert.ProtocolMessage) + if asJSON { + result := map[string]any{ + "cert_hash": cert.Hash, + "kind": "genesis", + "signed_message": cert.SignedMessage, + "epoch": cert.Epoch, + "verified": err == nil, + } + if err != nil { + result["error"] = err.Error() + } + code := emitJSON(result) + if err != nil { + return exitBadSig + } + return code + } + if err != nil { + fmt.Fprintln(os.Stderr, "verify genesis:", err) + return exitBadSig + } + fmt.Printf("genesis cert %s epoch=%d Ed25519 ✓\n", cert.Hash, cert.Epoch) + return exitOK } func cmdCert(ctx context.Context, args []string) int { diff --git a/internal/networks/networks.go b/internal/networks/networks.go index 6bd6010..bac7b67 100644 --- a/internal/networks/networks.go +++ b/internal/networks/networks.go @@ -18,12 +18,13 @@ var ( Preprod = Network{ Name: "preprod", AggregatorURL: "https://aggregator.release-preprod.api.mithril.network/aggregator", - GenesisVerifyKey: "5b3132372c37332c3132342c3136312c31362c38372c3133332c3136372c3135352c3138362c3138372c36372c3231322c37382c3131372c3230352c3234362c35322c35312c31372c3138302c38372c3130342c3139362c3131332c3130332c3239355d", // placeholder — replace with known-good key at implementation time + GenesisVerifyKey: "5b3132372c37332c3132342c3136312c362c3133372c3133312c3231332c3230372c3131372c3139382c38352c3137362c3139392c3136322c3234312c36382c3132332c3131392c3134352c31332c3233322c3234332c34392c3232392c322c3234392c3230352c3230352c33392c3233352c34345d", } Preview = Network{ Name: "preview", AggregatorURL: "https://aggregator.pre-release-preview.api.mithril.network/aggregator", - GenesisVerifyKey: "5b3132372c37332c3132342c3136312c31362c38372c3133332c3136372c3135352c3138362c3138372c36372c3231322c37382c3131372c3230352c3234362c35322c35312c31372c3138302c38372c3130342c3139362c3131332c3130332c3239355d", // placeholder + // Pre-release/preview uses the same genesis key as preprod. + GenesisVerifyKey: "5b3132372c37332c3132342c3136312c362c3133372c3133312c3231332c3230372c3131372c3139382c38352c3137362c3139392c3136322c3234312c36382c3132332c3131392c3134352c31332c3233322c3234332c34392c3232392c322c3234392c3230352c3230352c33392c3233352c34345d", } ) diff --git a/internal/verify/verify.go b/internal/verify/verify.go index b392e2c..ab02fce 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -2,60 +2,189 @@ // // Two layers of verification exist in Mithril: // -// 1. The genesis certificate is signed by a static Ed25519 key baked into -// the client (per-network). This bootstraps trust into the STM protocol. -// 2. Subsequent certificates carry an STM (Stake-based Threshold Multi- -// signature) aggregate signature over BLS12-381. Verification requires -// the stake distribution snapshot plus the signers' verification keys -// and their individual signature shares. +// 1. The genesis certificate is signed by a static Ed25519 key baked into +// the client (per-network). This bootstraps trust into the STM protocol. +// 2. Subsequent certificates carry an STM (Stake-based Threshold Multi- +// signature) aggregate signature over BLS12-381. // -// v1 scope: genesis Ed25519 verification only. STM/BLS verification is a -// separate follow-on milestone — it is the bulk of the cryptographic work -// in this project. +// Ed25519 (genesis) verification is fully implemented here. STM verification +// is stubbed pending the BLS crypto sprint. package verify import ( "crypto/ed25519" + "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" + "sort" + "strconv" + "strings" ) var ( - ErrNotGenesis = errors.New("certificate is not a genesis certificate") - ErrBadSignature = errors.New("genesis signature verification failed") + ErrNotGenesis = errors.New("certificate is not a genesis certificate") + ErrBadSignature = errors.New("genesis signature verification failed") + ErrSignedMessageHash = errors.New("signed_message does not match SHA256(protocol_message)") ErrSTMNotImplemented = errors.New("STM signature verification not implemented yet") ) -// Genesis verifies that the certificate was signed by the network's genesis -// verification key. signedPayload is the exact bytes the aggregator stated -// were signed (derived from the certificate's protocol_message, not this -// function's job to construct). -func Genesis(verifyKeyHex, genesisSignatureHex string, signedPayload []byte) error { - pkHex, err := hex.DecodeString(verifyKeyHex) - if err != nil { - return fmt.Errorf("decode verify key: %w", err) +// The Mithril enum order on ProtocolMessagePartKey — BTreeMap iteration +// follows variant declaration order, which is what compute_hash relies on. +// Keep this aligned with mithril-common/src/entities/protocol_message.rs. +var messagePartOrder = []string{ + "snapshot_digest", + "cardano_transactions_merkle_root", + "cardano_blocks_transactions_merkle_root", + "next_aggregate_verification_key", + "next_protocol_parameters", + "current_epoch", + "latest_block_number", + "cardano_blocks_transactions_block_number_offset", + "cardano_stake_distribution_epoch", + "cardano_stake_distribution_merkle_root", + "cardano_database_merkle_root", + "next_aggregate_verification_key_snark", +} + +// ComputeProtocolMessageHash mirrors Rust's ProtocolMessage::compute_hash: +// SHA256 over each (key_string || value_string) concatenated in +// BTreeMap order (Rust enum declaration order, not alphabetical). +// Returns the lowercase-hex form so it can be compared to the +// signed_message field directly. +func ComputeProtocolMessageHash(messageParts map[string]string) string { + h := sha256.New() + // iterate in canonical order + ordinal := make(map[string]int, len(messagePartOrder)) + for i, k := range messagePartOrder { + ordinal[k] = i } - // Mithril genesis keys are serialized as hex(ascii-of-byte-array-literal), - // e.g. "[191,66,140,...]" → outer hex → inner ASCII → parse. The real decoder - // will unpack this; for now accept a raw 32-byte hex as well. - pk := ed25519.PublicKey(pkHex) - if len(pk) != ed25519.PublicKeySize { - return fmt.Errorf("verify key wrong size: got %d, want %d", len(pk), ed25519.PublicKeySize) + keys := make([]string, 0, len(messageParts)) + for k := range messageParts { + if _, known := ordinal[k]; known { + keys = append(keys, k) + } else { + // Unknown key — placed at end, alphabetical. Safer than + // silently dropping; any future key we haven't mirrored yet + // still feeds into the digest, matching Rust's forward- + // compatibility. + keys = append(keys, k) + } + } + sort.Slice(keys, func(i, j int) bool { + oi, iOK := ordinal[keys[i]] + oj, jOK := ordinal[keys[j]] + switch { + case iOK && jOK: + return oi < oj + case iOK: + return true + case jOK: + return false + default: + return keys[i] < keys[j] + } + }) + for _, k := range keys { + h.Write([]byte(k)) + h.Write([]byte(messageParts[k])) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// ProtocolMessage is the subset of the Mithril protocol_message JSON shape +// that verification consumes. +type ProtocolMessage struct { + MessageParts map[string]string `json:"message_parts"` +} + +// DecodeGenesisVerifyKey unpacks the Mithril genesis verification key from +// its serialized form. The key is stored as: +// +// hex( ascii( "[b0,b1,...,b31]" ) ) +// +// i.e. the Ed25519 public key's 32 raw bytes are written as a Rust-debug +// byte-slice literal, converted to ASCII bytes, then hex-encoded. We +// strip both layers and return the 32-byte public key. +// +// Plain 64-char hex of the raw key is also accepted (some distributions +// ship the key that way). +func DecodeGenesisVerifyKey(encoded string) (ed25519.PublicKey, error) { + // Fast-path: plain 64-char hex of 32 raw bytes. + if len(encoded) == 2*ed25519.PublicKeySize { + if b, err := hex.DecodeString(encoded); err == nil && len(b) == ed25519.PublicKeySize { + return ed25519.PublicKey(b), nil + } + } + // Standard path: hex-of-ASCII-of-"[a,b,c,...]". + inner, err := hex.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode outer hex: %w", err) + } + s := strings.TrimSpace(string(inner)) + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + parts := strings.Split(s, ",") + if len(parts) != ed25519.PublicKeySize { + return nil, fmt.Errorf("genesis key literal has %d parts, want %d", len(parts), ed25519.PublicKeySize) + } + out := make([]byte, ed25519.PublicKeySize) + for i, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return nil, fmt.Errorf("parse byte %d: %w", i, err) + } + if n < 0 || n > 255 { + return nil, fmt.Errorf("byte %d out of range: %d", i, n) + } + out[i] = byte(n) + } + return ed25519.PublicKey(out), nil +} + +// Genesis verifies a genesis certificate: +// - signedMessageHex must equal SHA256(protocolMessage) in hex +// - the Ed25519 signature (64 bytes hex) over the ASCII bytes of +// signedMessageHex must verify against verifyKey +// +// Returns nil on success, a typed error on any failure. Matches the +// upstream `verify_genesis_certificate` semantics (integrity → +// signed_message → signature → epoch-match; the epoch check is the +// caller's job since it needs the full cert context). +func Genesis(verifyKey ed25519.PublicKey, signedMessageHex, genesisSignatureHex string, pm ProtocolMessage) error { + computed := ComputeProtocolMessageHash(pm.MessageParts) + if computed != signedMessageHex { + return fmt.Errorf("%w: got %s, want %s", ErrSignedMessageHash, computed, signedMessageHex) } sig, err := hex.DecodeString(genesisSignatureHex) if err != nil { return fmt.Errorf("decode signature: %w", err) } - if !ed25519.Verify(pk, signedPayload, sig) { + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("signature wrong size: got %d, want %d", len(sig), ed25519.SignatureSize) + } + // Per upstream: verify over signed_message.as_bytes() — the ASCII + // bytes of the hex string, not the 32 raw digest bytes. + if !ed25519.Verify(verifyKey, []byte(signedMessageHex), sig) { return ErrBadSignature } return nil } -// STM verifies a non-genesis certificate's aggregate BLS signature against -// the stake distribution. Stub — implementation target: Mithril STM paper -// §5 (signing protocol) + §6 (aggregation) using a BLS12-381 library. -func STM(protocolMessage, multiSignature []byte, stakeDistribution any) error { +// GenesisFromJSON is a convenience wrapper when the caller has the raw +// JSON protocol_message (as the aggregator returns). +func GenesisFromJSON(verifyKey ed25519.PublicKey, signedMessageHex, genesisSignatureHex string, protocolMessageJSON []byte) error { + var pm ProtocolMessage + if err := json.Unmarshal(protocolMessageJSON, &pm); err != nil { + return fmt.Errorf("parse protocol_message: %w", err) + } + return Genesis(verifyKey, signedMessageHex, genesisSignatureHex, pm) +} + +// STM verifies a non-genesis certificate's aggregate BLS signature. +// Stub — target is Mithril STM paper §5 (signing) + §6 (aggregation) +// using gnark-crypto's bls12-381 primitives. +func STM(protocolMessageJSON, multiSignature []byte, avk any) error { return ErrSTMNotImplemented }