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

View file

@ -0,0 +1,111 @@
//go:build live
package stm
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
// Fetches the head preprod cert, pulls out the (msg, vk_i, sigma_i) triple,
// and tries to BLS-verify ONE signer's sigma against signed_message.as_bytes().
//
// Expectation: this FAILS for a single signer because Mithril's STM layer
// aggregates sigs/vks before BLS-verifying, so an individual (sigma_i, vk_i)
// pair isn't a valid BLS signature of signed_message on its own.
//
// What we learn from this test:
//
// - If a single-signer verify passes: BLS works as expected (unlikely).
// - If it fails with "signature invalid" but no decode errors: our BLS impl
// is correct and the failure is a real property of STM (curve points
// decoded fine, pairing ran, result doesn't match — exactly what the
// aggregation layer fixes).
// - If it fails with a decode error: something in the wire format is off.
//
// This test documents expected behavior rather than asserting pass.
func TestBlsVerify_SingleSignerAgainstSignedMessage(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"`
SignedMessage string `json:"signed_message"`
AggregateVerificationKey json.RawMessage `json:"aggregate_verification_key"`
}
if err := json.Unmarshal(body, &cert); err != nil {
t.Fatal(err)
}
ms, err := DecodeMultiSig(cert.MultiSignature)
if err != nil {
t.Fatal(err)
}
if len(ms.Signatures) == 0 {
t.Fatal("no signers")
}
// Extract the AVK's mt_commitment.root — the BLS-verified message is
// msg || root, where msg is the ASCII bytes of signed_message hex.
avk, err := DecodeAVK(cert.AggregateVerificationKey)
if err != nil {
t.Fatalf("avk decode: %v", err)
}
t.Logf("avk mt_root=%x total_stake=%d nr_leaves=%d",
avk.MerkleRoot, avk.TotalStake, avk.NumLeaves)
msg := append([]byte(cert.SignedMessage), avk.MerkleRoot...)
s0 := ms.Signatures[0]
err = BlsVerify(msg, s0.Sig.Sigma, s0.RegParty.VK)
t.Logf("single-signer[0] BLS verify over signed_message: err=%v", err)
// Don't assert pass/fail here — documenting behavior.
// Aggregate all signers and verify — one entry per signer per lottery
// win (a signer with N wins contributes N copies of the same sigma/vk).
var sigs, vks [][]byte
for _, s := range ms.Signatures {
for range s.Sig.Indexes {
sigs = append(sigs, s.Sig.Sigma)
vks = append(vks, s.RegParty.VK)
}
}
t.Logf("aggregating %d (signer × win) pairs", len(sigs))
if err := BlsAggregateVerify(msg, sigs, vks); err != nil {
t.Logf("aggregate verify (with multiplicity = lottery wins): %v", err)
} else {
t.Logf("aggregate verify (with multiplicity = lottery wins): PASS ✓")
}
// Alternate: one entry per unique signer (no multiplicity)
sigs, vks = nil, nil
for _, s := range ms.Signatures {
sigs = append(sigs, s.Sig.Sigma)
vks = append(vks, s.RegParty.VK)
}
if err := BlsAggregateVerify(msg, sigs, vks); err != nil {
t.Logf("aggregate verify (unique signers only): %v", err)
} else {
t.Logf("aggregate verify (unique signers only): PASS ✓")
}
}