mithril-go/internal/stm/types.go
Cobb Hayes 7b9b09133b Public-flip audit: module URL + README humanization
git.sulkta.coop → git.sulkta.com (matches the live public Forgejo endpoint).
README dropped AI-agent positioning + emoji status table; kept all
technical content (DST, MuSig aggregation, exit codes, MCP tool table).
2026-05-27 11:29:05 -07:00

215 lines
6.6 KiB
Go

// Package stm implements Mithril Stake-based Threshold Multi-signature
// decoding and verification.
//
// The wire format of a Mithril multi_signature field is:
//
// hex( ASCII( JSON( ... ) ) )
//
// i.e. hex-encoded bytes that are the UTF-8 of a JSON object. The JSON
// contents are documented in DecodeMultiSig below.
//
// Verification phases:
//
// 1. DecodeMultiSig — parse the wrapped JSON
// 2. BLS single-sig verification of each (signer, sigma) over the msg
// 3. Merkle proof verification: each signer index is a registered party
// 4. Lottery check: for each (index, sigma), evaluate_dense_mapping < threshold(stake)
// 5. Threshold: total distinct lottery wins >= k
//
// Phases 2-5 are implemented in verify.go (BLS, lottery, Merkle, threshold).
package stm
import (
"encoding/hex"
"encoding/json"
"fmt"
)
// ByteArray decodes from either a JSON array of ints [1,2,3] (Mithril's
// on-wire shape) or a base64 string (Go's default []byte handling).
// Always emits an array of ints for forward compatibility.
type ByteArray []byte
func (b *ByteArray) UnmarshalJSON(data []byte) error {
// Try array-of-ints first — this is what Mithril ships.
var ints []int
if err := json.Unmarshal(data, &ints); err == nil {
out := make([]byte, len(ints))
for i, v := range ints {
if v < 0 || v > 255 {
return fmt.Errorf("byte out of range at %d: %d", i, v)
}
out[i] = byte(v)
}
*b = out
return nil
}
// Fallback: base64 string.
var s string
if err := json.Unmarshal(data, &s); err == nil {
*b = []byte(s)
return nil
}
return fmt.Errorf("ByteArray: neither int-array nor string")
}
// MultiSig is the decoded top-level shape.
type MultiSig struct {
Signatures []SignerEntry `json:"signatures"`
BatchProof BatchProof `json:"batch_proof"`
}
// SignerEntry is a 2-tuple serialized as a JSON array: (StmSig, RegParty).
// We decode it via a custom UnmarshalJSON because JSON heterogeneous
// arrays don't map to Go structs directly.
type SignerEntry struct {
Sig StmSig
RegParty RegParty
}
// StmSig is one signer's contribution: their BLS sig, the lottery
// indices they won, and their index in the registered party list.
type StmSig struct {
Sigma ByteArray `json:"sigma"` // 48-byte BLS G1 compressed sig
Indexes []uint64 `json:"indexes"` // winning lottery indices
SignerIndex uint64 `json:"signer_index"` // party position in registered list
}
// RegParty is also a 2-tuple in JSON: (vk_bytes, stake).
type RegParty struct {
VK ByteArray // 96-byte BLS G2 compressed verification key
Stake uint64
}
// AVK is the cert's aggregate verification key — a Merkle commitment over
// the registered (vk, stake) parties plus the total stake. Shipped on the
// wire as hex-of-ASCII-of-JSON, same wrapping as MultiSig.
type AVK struct {
MerkleRoot ByteArray
NumLeaves uint64
TotalStake uint64
}
// DecodeAVK decodes the wrapped JSON of a Mithril aggregate_verification_key
// field.
func DecodeAVK(rawJSON []byte) (*AVK, error) {
hexStr := string(rawJSON)
if len(hexStr) >= 2 && hexStr[0] == '"' && hexStr[len(hexStr)-1] == '"' {
hexStr = hexStr[1 : len(hexStr)-1]
}
data, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("AVK hex: %w", err)
}
var wire struct {
MTCommitment struct {
Root ByteArray `json:"root"`
NrLeaves uint64 `json:"nr_leaves"`
Hasher any `json:"hasher"`
} `json:"mt_commitment"`
TotalStake uint64 `json:"total_stake"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return nil, fmt.Errorf("AVK json: %w", err)
}
if len(wire.MTCommitment.Root) != 32 {
return nil, fmt.Errorf("AVK root: got %d bytes, want 32", len(wire.MTCommitment.Root))
}
if wire.TotalStake == 0 {
return nil, fmt.Errorf("AVK total_stake is zero")
}
if wire.MTCommitment.NrLeaves == 0 {
return nil, fmt.Errorf("AVK nr_leaves is zero")
}
return &AVK{
MerkleRoot: wire.MTCommitment.Root,
NumLeaves: wire.MTCommitment.NrLeaves,
TotalStake: wire.TotalStake,
}, nil
}
// BatchProof is a Merkle multi-proof over the registered parties.
type BatchProof struct {
Values []ByteArray `json:"values"` // proof nodes, each 32 bytes (BLAKE2b-256)
Indices []uint64 `json:"indices"` // signer indices being proven
Hasher any `json:"hasher"` // null => BLAKE2b-256 default
}
// UnmarshalJSON for SignerEntry — decode the [StmSig, RegParty] tuple.
func (s *SignerEntry) UnmarshalJSON(b []byte) error {
var raw [2]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("SignerEntry tuple: %w", err)
}
if err := json.Unmarshal(raw[0], &s.Sig); err != nil {
return fmt.Errorf("SignerEntry.Sig: %w", err)
}
if err := s.RegParty.UnmarshalJSON(raw[1]); err != nil {
return fmt.Errorf("SignerEntry.RegParty: %w", err)
}
return nil
}
// UnmarshalJSON for RegParty — decode the [vk_bytes, stake] tuple.
func (r *RegParty) UnmarshalJSON(b []byte) error {
var raw [2]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return fmt.Errorf("RegParty tuple: %w", err)
}
if err := r.VK.UnmarshalJSON(raw[0]); err != nil {
return fmt.Errorf("RegParty.VK: %w", err)
}
if err := json.Unmarshal(raw[1], &r.Stake); err != nil {
return fmt.Errorf("RegParty.Stake: %w", err)
}
return nil
}
// DecodeMultiSig takes the raw `multi_signature` field value from a
// Mithril certificate (a JSON string whose contents are hex-encoded
// UTF-8 JSON) and returns the decoded struct.
//
// If rawJSON begins with a JSON string quote, the quotes are stripped
// first; this lets callers pass either the json.RawMessage form or an
// already-unquoted hex string.
func DecodeMultiSig(rawJSON []byte) (*MultiSig, error) {
hexStr := string(rawJSON)
if len(hexStr) >= 2 && hexStr[0] == '"' && hexStr[len(hexStr)-1] == '"' {
hexStr = hexStr[1 : len(hexStr)-1]
}
data, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("hex decode: %w", err)
}
var ms MultiSig
if err := json.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("json decode: %w", err)
}
return &ms, nil
}
// TotalWins counts the total number of lottery wins across all signers.
func (m *MultiSig) TotalWins() int {
n := 0
for _, s := range m.Signatures {
n += len(s.Sig.Indexes)
}
return n
}
// DistinctWins returns the set of distinct lottery indices claimed
// across all signers. The Mithril STM spec requires total DISTINCT
// indices >= k for a valid aggregate.
func (m *MultiSig) DistinctWins() []uint64 {
seen := make(map[uint64]struct{})
for _, s := range m.Signatures {
for _, ix := range s.Sig.Indexes {
seen[ix] = struct{}{}
}
}
out := make([]uint64, 0, len(seen))
for ix := range seen {
out = append(out, ix)
}
return out
}