mithril-go/internal/stm/aggregate.go
Kayos 32f0057700 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.
2026-04-23 15:53:00 -07:00

143 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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