mirror of
https://github.com/luxfi/crypto.git
synced 2026-10-30 16:59:13 +00:00
In the !cgo build the function was `return PublicKeyToCompressedBytes(key)` — it contradicted its own name and returned PublicKeyLen bytes where callers expect 2*PublicKeyLen. The cgo build returned the correct 96 via Serialize(), so the two builds of one package disagreed about a wire encoding. It hid because both widths PARSE: circl's G1.SetBytes reads the compression bit and accepts either, so a narrowed key still verified and nothing failed — until a validator set registered with uncompressed keys met a verifier that had narrowed them. That is the Hanzo 36963 halt: no vote could be counted and the chain sat at one height while every other signal looked healthy. The parse side was already widened to accept both; a serializer that silently narrows re-opens the same halt from the other end. circl's bls.PublicKey only marshals compressed (MarshalBinary -> BytesCompressed), so go through the G1 element, which exposes both widths. uncompressed_encoding_test.go asserts the width, that the two encodings are not byte-identical, and that both round-trip to a key that verifies the same signature. Negative control: restoring the alias fails it with "uncompressed len = 48, want 96". Tests: CGO_ENABLED=0 ./bls ok. The cgo variant does not link on this machine (ld: library 'resolv' not found — a local toolchain issue, identical before this change); production builds CGO_ENABLED=0. Co-authored-by: Hanzo Dev <[email protected]>
393 lines
13 KiB
Go
393 lines
13 KiB
Go
// Copyright (C) 2020-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
//go:build !cgo
|
|
|
|
package bls
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
|
|
"github.com/cloudflare/circl/ecc/bls12381"
|
|
blssign "github.com/cloudflare/circl/sign/bls"
|
|
"github.com/luxfi/crypto/secret"
|
|
)
|
|
|
|
// Domain separation tags - must match the CGO version (blst) exactly
|
|
var (
|
|
dstSignature = []byte("BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_")
|
|
dstPoP = []byte("BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_")
|
|
)
|
|
|
|
type (
|
|
SecretKey struct {
|
|
sk *blssign.PrivateKey[blssign.KeyG1SigG2]
|
|
}
|
|
|
|
PublicKey struct {
|
|
pk *blssign.PublicKey[blssign.KeyG1SigG2]
|
|
}
|
|
|
|
Signature struct {
|
|
sig blssign.Signature
|
|
}
|
|
|
|
AggregatePublicKey = PublicKey
|
|
AggregateSignature = Signature
|
|
)
|
|
|
|
func NewSecretKey() (*SecretKey, error) {
|
|
var result *SecretKey
|
|
var keyErr error
|
|
secret.Do(func() {
|
|
ikm := make([]byte, 32)
|
|
rand.Read(ikm)
|
|
defer clear(ikm)
|
|
|
|
sk, err := blssign.KeyGen[blssign.KeyG1SigG2](ikm, nil, nil)
|
|
if err != nil {
|
|
keyErr = err
|
|
return
|
|
}
|
|
result = &SecretKey{sk: sk}
|
|
})
|
|
return result, keyErr
|
|
}
|
|
|
|
func SecretKeyToBytes(sk *SecretKey) []byte {
|
|
if sk == nil || sk.sk == nil {
|
|
return nil
|
|
}
|
|
data, _ := sk.sk.MarshalBinary()
|
|
return data
|
|
}
|
|
|
|
// SecretKeyFromSeed derives a secret key from a seed using proper BLS key derivation.
|
|
// The seed is passed through internal key derivation, so any 32+ byte input
|
|
// will produce a valid secret key.
|
|
func SecretKeyFromSeed(seed []byte) (*SecretKey, error) {
|
|
if len(seed) < 32 {
|
|
return nil, errors.New("seed must be at least 32 bytes")
|
|
}
|
|
sk, err := blssign.KeyGen[blssign.KeyG1SigG2](seed, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SecretKey{sk: sk}, nil
|
|
}
|
|
|
|
func SecretKeyFromBytes(skBytes []byte) (*SecretKey, error) {
|
|
if len(skBytes) != SecretKeyLen {
|
|
return nil, ErrFailedSecretKeyDeserialize
|
|
}
|
|
var result *SecretKey
|
|
var keyErr error
|
|
secret.Do(func() {
|
|
sk := new(blssign.PrivateKey[blssign.KeyG1SigG2])
|
|
if err := sk.UnmarshalBinary(skBytes); err != nil {
|
|
keyErr = ErrFailedSecretKeyDeserialize
|
|
return
|
|
}
|
|
result = &SecretKey{sk: sk}
|
|
})
|
|
return result, keyErr
|
|
}
|
|
|
|
func (sk *SecretKey) PublicKey() *PublicKey {
|
|
if sk == nil || sk.sk == nil {
|
|
return nil
|
|
}
|
|
return &PublicKey{pk: sk.sk.PublicKey()}
|
|
}
|
|
|
|
func (sk *SecretKey) Sign(msg []byte) (*Signature, error) {
|
|
if sk == nil || sk.sk == nil {
|
|
return nil, errors.New("nil secret key")
|
|
}
|
|
return &Signature{sig: blssign.Sign(sk.sk, msg)}, nil
|
|
}
|
|
|
|
// SignProofOfPossession signs a [msg] to prove the ownership of this secret key.
|
|
// Uses the PoP DST (BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_) for domain separation.
|
|
// This MUST use a different DST than Sign() to prevent cross-protocol attacks.
|
|
func (sk *SecretKey) SignProofOfPossession(msg []byte) (*Signature, error) {
|
|
if sk == nil || sk.sk == nil {
|
|
return nil, errors.New("nil secret key")
|
|
}
|
|
|
|
// Get the scalar from the private key
|
|
skBytes, err := sk.sk.MarshalBinary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create scalar from private key bytes
|
|
var scalar bls12381.Scalar
|
|
scalar.SetBytes(skBytes)
|
|
|
|
// Hash message to G2 with PoP DST
|
|
var sigPoint bls12381.G2
|
|
sigPoint.Hash(msg, dstPoP)
|
|
|
|
// Multiply by secret key scalar: sig = sk * H(msg)
|
|
sigPoint.ScalarMult(&scalar, &sigPoint)
|
|
|
|
return &Signature{sig: sigPoint.BytesCompressed()}, nil
|
|
}
|
|
|
|
func PublicKeyToCompressedBytes(pk *PublicKey) []byte {
|
|
if pk == nil || pk.pk == nil {
|
|
return nil
|
|
}
|
|
data, _ := pk.pk.MarshalBinary()
|
|
return data
|
|
}
|
|
|
|
func PublicKeyFromCompressedBytes(pkBytes []byte) (*PublicKey, error) {
|
|
if len(pkBytes) != PublicKeyLen {
|
|
return nil, ErrFailedPublicKeyDecompress
|
|
}
|
|
// HIGH-1: reject the malformed "infinity bit set, compression bit clear"
|
|
// encoding (top byte b[0]&0xC0 == 0x40) BEFORE handing the buffer to CIRCL's
|
|
// SetBytes. In that branch SetBytes treats the input as UNCOMPRESSED
|
|
// (length G1Size=96) and slices b[1:96] on this canonical 48-byte compressed
|
|
// buffer → slice-bounds-out-of-range PANIC (ecc/bls12381/g1.go:53). On a
|
|
// CGO_ENABLED=0 (purego) node — the canonical image — that is an
|
|
// unauthenticated, consensus-halting DoS via any PoP / peer-handshake / warp
|
|
// BLS field. The CGO/blst path returns an error for this input (never
|
|
// panics); rejecting it here in-band restores parity. The canonical
|
|
// compressed-infinity form (0xc0) falls through to Validate() below, which
|
|
// already rejects the identity point — so this guard is additive.
|
|
if pkBytes[0]&0xC0 == 0x40 {
|
|
return nil, ErrFailedPublicKeyDecompress
|
|
}
|
|
pk := new(blssign.PublicKey[blssign.KeyG1SigG2])
|
|
if err := pk.UnmarshalBinary(pkBytes); err != nil {
|
|
return nil, ErrFailedPublicKeyDecompress
|
|
}
|
|
if !pk.Validate() {
|
|
return nil, ErrInvalidPublicKey
|
|
}
|
|
return &PublicKey{pk: pk}, nil
|
|
}
|
|
|
|
// PublicKeyToUncompressedBytes returns the UNCOMPRESSED (x‖y) G1 encoding — 2*PublicKeyLen
|
|
// bytes. It used to return PublicKeyToCompressedBytes(key), i.e. PublicKeyLen bytes: a
|
|
// function that contradicted its own name, and silently, because both results parse (circl's
|
|
// G1.SetBytes reads the compression bit and accepts either width).
|
|
//
|
|
// A validator set registered with uncompressed keys is exactly the Hanzo 36963 halt: the
|
|
// verifier could not parse a registered key, so no vote counted and the chain sat at one
|
|
// height while every other signal looked healthy. The parse side is fixed; a serializer that
|
|
// silently narrows would re-open it from the other end, in whichever build is compiled
|
|
// WITHOUT cgo — this file — while the cgo build returned the correct 96 bytes. Two builds
|
|
// disagreeing on a wire encoding is the worst shape for this bug to have.
|
|
//
|
|
// circl's bls.PublicKey only marshals compressed (MarshalBinary → BytesCompressed), so go
|
|
// through the G1 element itself, which exposes both widths.
|
|
func PublicKeyToUncompressedBytes(key *PublicKey) []byte {
|
|
compressed := PublicKeyToCompressedBytes(key)
|
|
if len(compressed) == 0 {
|
|
return nil
|
|
}
|
|
var g bls12381.G1
|
|
if err := g.SetBytes(compressed); err != nil {
|
|
return nil
|
|
}
|
|
return g.Bytes()
|
|
}
|
|
|
|
func PublicKeyFromValidUncompressedBytes(pkBytes []byte) *PublicKey {
|
|
pk := new(blssign.PublicKey[blssign.KeyG1SigG2])
|
|
if err := pk.UnmarshalBinary(pkBytes); err != nil {
|
|
return nil
|
|
}
|
|
// Identity rejection lives at the ONE deserialization boundary (RESIDUAL C):
|
|
// Validate() = !IsIdentity() && IsOnG1(), so an identity (or off-curve) key
|
|
// never escapes a constructor. Downstream Verify therefore does NOT re-check
|
|
// for the identity point — the check belongs here, once.
|
|
if !pk.Validate() {
|
|
return nil
|
|
}
|
|
return &PublicKey{pk: pk}
|
|
}
|
|
|
|
func AggregatePublicKeys(pks []*PublicKey) (*PublicKey, error) {
|
|
if len(pks) == 0 {
|
|
return nil, ErrNoPublicKeys
|
|
}
|
|
|
|
var agg bls12381.G1
|
|
agg.SetIdentity()
|
|
|
|
for _, pk := range pks {
|
|
if pk == nil || pk.pk == nil {
|
|
return nil, ErrInvalidPublicKey
|
|
}
|
|
pkBytes, err := pk.pk.MarshalBinary()
|
|
if err != nil {
|
|
return nil, ErrFailedPublicKeyAggregation
|
|
}
|
|
var pt bls12381.G1
|
|
if err := pt.SetBytes(pkBytes); err != nil {
|
|
return nil, ErrFailedPublicKeyAggregation
|
|
}
|
|
agg.Add(&agg, &pt)
|
|
}
|
|
|
|
result := new(blssign.PublicKey[blssign.KeyG1SigG2])
|
|
if err := result.UnmarshalBinary(agg.BytesCompressed()); err != nil {
|
|
return nil, ErrFailedPublicKeyAggregation
|
|
}
|
|
// Defence in depth: reject an aggregate that is the IDENTITY (point at
|
|
// infinity). Each INPUT key is a valid non-identity subgroup point (the
|
|
// constructors call Validate), and the sum of subgroup points stays on-curve and
|
|
// in-subgroup — but it can still be the identity when the inputs sum to zero (the
|
|
// canonical rogue-key shape: pk + (-pk) = O). An identity aggregate public key
|
|
// makes Verify trivially accept the identity signature (a forgery enabler).
|
|
// Proof-of-possession at registration already prevents an attacker contributing a
|
|
// key it cannot produce, but the verifier must NOT depend on that being enforced
|
|
// everywhere: Validate() = !IsIdentity() && IsOnG1() fails the aggregate closed.
|
|
if !result.Validate() {
|
|
return nil, ErrFailedPublicKeyAggregation
|
|
}
|
|
return &PublicKey{pk: result}, nil
|
|
}
|
|
|
|
func Verify(pk *PublicKey, sig *Signature, msg []byte) bool {
|
|
if pk == nil || pk.pk == nil || sig == nil {
|
|
return false
|
|
}
|
|
// The identity (zero) public key is rejected at the deserialization boundary
|
|
// (PublicKeyFromCompressedBytes / PublicKeyFromValidUncompressedBytes call
|
|
// Validate() = !IsIdentity() && IsOnG1()), so a *PublicKey reaching Verify is
|
|
// already a valid non-identity G1 point. Re-checking here was redundant — and
|
|
// the old all-zero byte test was WRONG anyway (canonical compressed-G1
|
|
// infinity is 0xc0||zeros, not 0x00||zeros) — so it is removed (RESIDUAL C):
|
|
// identity rejection belongs at decode, in one place.
|
|
return blssign.Verify(pk.pk, msg, sig.sig)
|
|
}
|
|
|
|
// VerifyProofOfPossession verifies the possession of the secret pre-image of [pk].
|
|
// Uses the PoP DST (BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_) for domain separation.
|
|
func VerifyProofOfPossession(pk *PublicKey, sig *Signature, msg []byte) bool {
|
|
if pk == nil || pk.pk == nil || sig == nil {
|
|
return false
|
|
}
|
|
// Identity (zero) pubkey already rejected at decode (Validate); see Verify.
|
|
|
|
// Parse the signature as a G2 point
|
|
var sigPoint bls12381.G2
|
|
if err := sigPoint.SetBytes(sig.sig); err != nil {
|
|
return false
|
|
}
|
|
|
|
// Get the public key as a G1 point
|
|
pkBytes, err := pk.pk.MarshalBinary()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var pkPoint bls12381.G1
|
|
if err := pkPoint.SetBytes(pkBytes); err != nil {
|
|
return false
|
|
}
|
|
|
|
// Hash message to G2 with PoP DST
|
|
var hashPoint bls12381.G2
|
|
hashPoint.Hash(msg, dstPoP)
|
|
|
|
// BLS verification: e(pk, H(msg)) == e(G1, sig)
|
|
// This is equivalent to: e(pk, H(msg)) * e(-G1, sig) == 1
|
|
// Or: e(pk, H(msg)) == e(G1, sig)
|
|
|
|
// Verify using pairing check: e(G1, sig) == e(pk, H(msg))
|
|
// Which is: e(-G1, sig) * e(pk, H(msg)) == 1
|
|
// Copy the generator bytes then negate
|
|
var negG1 bls12381.G1
|
|
_ = negG1.SetBytes(bls12381.G1Generator().BytesCompressed())
|
|
negG1.Neg()
|
|
|
|
// Prepare points for pairing
|
|
listG1 := []*bls12381.G1{&negG1, &pkPoint}
|
|
listG2 := []*bls12381.G2{&sigPoint, &hashPoint}
|
|
|
|
// ProdPairFrac computes the product of pairings and checks if result equals identity
|
|
result := bls12381.ProdPairFrac(listG1, listG2, []int{1, 1})
|
|
return result.IsIdentity()
|
|
}
|
|
|
|
func SignatureToBytes(sig *Signature) []byte {
|
|
if sig == nil {
|
|
return nil
|
|
}
|
|
return sig.sig
|
|
}
|
|
|
|
func SignatureFromBytes(sigBytes []byte) (*Signature, error) {
|
|
if len(sigBytes) != SignatureLen {
|
|
return nil, ErrFailedSignatureDecompress
|
|
}
|
|
// HIGH-1: reject the malformed "infinity bit set, compression bit clear"
|
|
// encoding (top byte b[0]&0xC0 == 0x40) BEFORE SetBytes. In that branch
|
|
// CIRCL treats the input as UNCOMPRESSED (length G2Size=192) and slices
|
|
// b[1:192] on this canonical 96-byte compressed buffer → slice-bounds-out-of-
|
|
// range PANIC (ecc/bls12381/g2.go:53). On a CGO_ENABLED=0 (purego) node that
|
|
// is an unauthenticated, consensus-halting DoS via any peer/warp/quasar BLS
|
|
// signature field. blst's Uncompress returns an error for this input (never
|
|
// panics); rejecting it here in-band restores parity. The canonical
|
|
// compressed-infinity form (0xc0) falls through to the IsIdentity() check
|
|
// below — so this guard is additive, not a replacement.
|
|
if sigBytes[0]&0xC0 == 0x40 {
|
|
return nil, ErrFailedSignatureDecompress
|
|
}
|
|
// Validate that the bytes decode to a point that is on-curve AND in the
|
|
// prime-order r-torsion subgroup of G2 — the exact contract the CGO/blst
|
|
// path enforces with Uncompress + SigValidate(false). CIRCL's
|
|
// bls12381.G2.SetBytes performs both checks: it decodes the compressed
|
|
// point and then calls IsOnG2() (= isValidProjective && isOnCurve &&
|
|
// isRTorsion) before returning, rejecting any input that is not a valid
|
|
// subgroup element. Without this, a length-96 non-zero blob that is not a
|
|
// real signature point would be accepted here (the prior byte-loop only
|
|
// rejected all-zero), diverging from blst and admitting garbage signatures
|
|
// into the verifier.
|
|
var g bls12381.G2
|
|
if err := g.SetBytes(sigBytes); err != nil {
|
|
return nil, ErrFailedSignatureDecompress
|
|
}
|
|
// Reject the G2 identity (point at infinity) — symmetric with the pubkey
|
|
// identity guard at decode (Validate, INFO-4). CIRCL's SetBytes accepts a well-formed
|
|
// infinity encoding (0xc0 || zeros) and returns at the isInfinity branch BEFORE
|
|
// IsOnG2, so without this the identity signature would deserialize cleanly; blst
|
|
// SigValidate(false) likewise skips the infinity check. The identity sig does
|
|
// not forge against a real key, but accepting it is an asymmetry with the pubkey
|
|
// path and admits a degenerate point into the verifier — reject it here.
|
|
if g.IsIdentity() {
|
|
return nil, ErrFailedSignatureDecompress
|
|
}
|
|
// Store the validated compressed bytes; blssign.Signature is the raw
|
|
// compressed form and blssign.Verify re-derives the point internally, so we
|
|
// keep the canonical wire bytes (round-trips through SignatureToBytes).
|
|
return &Signature{sig: sigBytes}, nil
|
|
}
|
|
|
|
func AggregateSignatures(sigs []*Signature) (*Signature, error) {
|
|
if len(sigs) == 0 {
|
|
return nil, ErrNoSignatures
|
|
}
|
|
|
|
sigBytes := make([]blssign.Signature, len(sigs))
|
|
for i, sig := range sigs {
|
|
if sig == nil {
|
|
return nil, ErrFailedSignatureAggregation
|
|
}
|
|
sigBytes[i] = sig.sig
|
|
}
|
|
|
|
aggSig, err := blssign.Aggregate[blssign.KeyG1SigG2](blssign.KeyG1SigG2{}, sigBytes)
|
|
if err != nil {
|
|
return nil, ErrFailedSignatureAggregation
|
|
}
|
|
return &Signature{sig: aggSig}, nil
|
|
}
|