STM BLS verification WORKING against live preprod (milestones A, B, partial C)

Key findings from upstream:
- Mithril's BLS msg is NOT signed_message alone — it's
  msgp = signed_message_ascii_bytes || mt_commitment_root_32_bytes
- Mithril uses EMPTY DST for hash-to-G1 (not the IETF BLS suite string)
- Aggregation is NOT plain summation — it's MuSig-style weighted:
  t_i = Blake2b-128(Blake2b-128(sigs_concat) || be_u64(i))
  aggr_sig = Σ t_i · sig_i      (in G1)
  aggr_vk  = Σ t_i · vk_i       (in G2)
  This blocks rogue-key attacks.

Shipped:
- internal/stm/types.go: MultiSig + AVK decoders (hex-of-ASCII-JSON wrapping,
  polymorphic tuple JSON handling via ByteArray + custom UnmarshalJSON)
- internal/stm/bls.go: BlsVerify (pairing check with gnark-crypto)
- internal/stm/aggregate.go: MuSig-style AggregateBLS + BlsAggregateVerify
- synthetic test + live test (build tag 'live') both green

Live preprod head cert (epoch 284, cert 175051cf…):
- 2 signers, 11 total lottery wins
- aggregate verify: PASS ✓
- single-signer verify: PASS ✓

Next: lottery threshold check, Merkle batch-proof verification, glue into
top-level Verify(msg, multi_sig, avk, params) + wire to 'verify' subcommand.
This commit is contained in:
Sulkta 2026-04-23 15:53:00 -07:00
parent 29903bb976
commit c1305913c2
8 changed files with 705 additions and 0 deletions

143
internal/stm/aggregate.go Normal file
View file

@ -0,0 +1,143 @@
package stm
import (
"encoding/binary"
"fmt"
"math/big"
bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381"
"golang.org/x/crypto/blake2b"
)
// AggregateBLS performs Mithril's MuSig-style weighted aggregation.
//
// For each signer i with signature σ_i and verification key vk_i:
//
// h₀ = Blake2b-128(σ_0 ‖ σ_1 ‖ … ‖ σ_{n-1})
// t_i = Blake2b-128(h₀ ‖ i_be_u64) // 128-bit scalar
// aggr_σ = Σ t_i · σ_i // in G1
// aggr_vk = Σ t_i · vk_i // in G2
//
// Matches mithril-stm::bls_multi_signature::signature::BlsSignature::aggregate.
// The per-signer scalar prevents rogue-key attacks where an adversary chooses
// their vk to cancel the contribution of honest signers.
//
// Single-signer short-circuit: when n == 1 the function returns the raw
// sig/vk unchanged, matching blst's `if vks.len() < 2 { return (vks[0], sigs[0]) }`.
func AggregateBLS(sigs, vks [][]byte) (aggSig, aggVK []byte, err error) {
if len(sigs) == 0 || len(sigs) != len(vks) {
return nil, nil, fmt.Errorf("sig/vk mismatch: %d/%d", len(sigs), len(vks))
}
if len(sigs) == 1 {
// No aggregation needed; blst returns the single contribution as-is.
return sigs[0], vks[0], nil
}
// Decode all points + subgroup-check up front.
sigPts := make([]bls12381.G1Affine, len(sigs))
vkPts := make([]bls12381.G2Affine, len(vks))
for i := range sigs {
if len(sigs[i]) != bls12381.SizeOfG1AffineCompressed {
return nil, nil, fmt.Errorf("sig[%d] size %d", i, len(sigs[i]))
}
if _, err := sigPts[i].SetBytes(sigs[i]); err != nil {
return nil, nil, fmt.Errorf("sig[%d] decode: %w", i, err)
}
if !sigPts[i].IsInSubGroup() {
return nil, nil, fmt.Errorf("sig[%d] not in subgroup", i)
}
if len(vks[i]) != bls12381.SizeOfG2AffineCompressed {
return nil, nil, fmt.Errorf("vk[%d] size %d", i, len(vks[i]))
}
if _, err := vkPts[i].SetBytes(vks[i]); err != nil {
return nil, nil, fmt.Errorf("vk[%d] decode: %w", i, err)
}
if !vkPts[i].IsInSubGroup() {
return nil, nil, fmt.Errorf("vk[%d] not in subgroup", i)
}
}
// Step 1: h₀ = Blake2b-128(σ_0 ‖ σ_1 ‖ … ‖ σ_{n-1})
//
// Note: the Rust impl updates the hasher with `sig.to_bytes()`, which is
// the compressed 48-byte encoding. We use the input bytes directly —
// they're the same thing, the signer ships compressed form on the wire.
base, err := blake2b.New(16, nil) // 128-bit output
if err != nil {
return nil, nil, err
}
for _, s := range sigs {
base.Write(s)
}
baseDigest := base.Sum(nil) // 16 bytes
// Step 2: scalars t_i = Blake2b-128(h₀ ‖ be_u64(i))
//
// Critical detail: the Rust uses `index.to_be_bytes()` where `index` is
// `usize`. On 64-bit platforms that's 8 BE bytes. blst_lib then treats
// the resulting 128-bit scalar as little-endian when doing the mult.
scalars := make([]*big.Int, len(sigs))
idxBuf := make([]byte, 8)
for i := range sigs {
h, err := blake2b.New(16, nil)
if err != nil {
return nil, nil, err
}
h.Write(baseDigest)
binary.BigEndian.PutUint64(idxBuf, uint64(i))
h.Write(idxBuf)
digest := h.Sum(nil) // 16 bytes LE
// blst interprets the scalar blob as little-endian; gnark's
// ScalarMultiplication takes a big.Int (treated as absolute
// value mod r). Interpret as LE to match blst.
scalars[i] = new(big.Int).SetBytes(reverseBytes(digest))
}
// Step 3: aggr_sig = Σ t_i · σ_i
var aggSigJac bls12381.G1Jac
for i := range sigPts {
var scaled bls12381.G1Affine
scaled.ScalarMultiplication(&sigPts[i], scalars[i])
var jac bls12381.G1Jac
jac.FromAffine(&scaled)
aggSigJac.AddAssign(&jac)
}
var aggSigAff bls12381.G1Affine
aggSigAff.FromJacobian(&aggSigJac)
sigBytes := aggSigAff.Bytes()
// aggr_vk = Σ t_i · vk_i
var aggVKJac bls12381.G2Jac
for i := range vkPts {
var scaled bls12381.G2Affine
scaled.ScalarMultiplication(&vkPts[i], scalars[i])
var jac bls12381.G2Jac
jac.FromAffine(&scaled)
aggVKJac.AddAssign(&jac)
}
var aggVKAff bls12381.G2Affine
aggVKAff.FromJacobian(&aggVKJac)
vkBytes := aggVKAff.Bytes()
return sigBytes[:], vkBytes[:], nil
}
func reverseBytes(b []byte) []byte {
out := make([]byte, len(b))
for i, v := range b {
out[len(b)-1-i] = v
}
return out
}
// BlsAggregateVerify aggregates per-signer contributions (with the
// Mithril MuSig-style scalar weighting) and does one pairing check.
// Does NOT check lottery wins, Merkle membership, or the k-threshold —
// those are separate STM-layer checks.
func BlsAggregateVerify(msg []byte, sigs, vks [][]byte) error {
aggSig, aggVK, err := AggregateBLS(sigs, vks)
if err != nil {
return fmt.Errorf("aggregate: %w", err)
}
return BlsVerify(msg, aggSig, aggVK)
}