bls(purego): validate G2 signature point (on-curve + r-torsion subgroup) in SignatureFromBytes

The //go:build !cgo (CIRCL) SignatureFromBytes stored any length-96 non-zero
blob as a Signature with NO point validation, diverging from the CGO/blst path
(Uncompress + SigValidate(false)) and admitting garbage signatures into the
verifier. Parse the input through bls12381.G2.SetBytes, which decodes the
compressed point and calls IsOnG2() = isValidProjective && isOnCurve &&
isRTorsion (the prime-order r-torsion SUBGROUP check) before returning — the
exact analogue of blst SigValidate(false). PublicKeyFromCompressedBytes already
calls pk.Validate(); this restores symmetry on the signature side.

Adds a !cgo-tagged regression test proving the behavioral change: a non-G2
garbage blob the old byte-loop accepted is now rejected; real signatures still
round-trip; all-zero and wrong-length stay rejected. CIRCL exposes no public
API to mint an on-curve-non-subgroup point, so the subgroup leg is enforced by
SetBytes (verified at ecc/bls12381/g2.go:86) and source review.
This commit is contained in:
zeekay
2026-06-22 18:25:33 -07:00
parent fae2d6ad56
commit be7dbb79d5
2 changed files with 119 additions and 5 deletions
+17 -5
View File
@@ -287,12 +287,24 @@ func SignatureFromBytes(sigBytes []byte) (*Signature, error) {
if len(sigBytes) != SignatureLen {
return nil, ErrFailedSignatureDecompress
}
for _, b := range sigBytes {
if b != 0 {
return &Signature{sig: sigBytes}, nil
}
// 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
}
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) {
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package bls
import (
"bytes"
"testing"
"github.com/cloudflare/circl/ecc/bls12381"
)
// TestSignatureFromBytes_RejectsNonG2_PuregoParity is the regression test for
// the purego (CIRCL, //go:build !cgo) SignatureFromBytes point-validation fix.
//
// BEFORE the fix, the purego path accepted ANY length-96 input that had at
// least one non-zero byte — it performed NO on-curve and NO subgroup check,
// storing the raw bytes as a "Signature". That diverged from the CGO/blst path
// (Uncompress + SigValidate(false), which rejects non-G2 points) and admitted
// garbage signatures into the verifier.
//
// AFTER the fix, SignatureFromBytes parses the input through
// bls12381.G2.SetBytes, which decodes the compressed point and then calls
// IsOnG2() — isValidProjective() && isOnCurve() && isRTorsion() — before
// returning (CIRCL v1.6.3 ecc/bls12381/g2.go:86). isRTorsion() is the
// prime-order r-torsion SUBGROUP check (Bowe, eprint 2019/814), so SetBytes is
// the exact analogue of blst's SigValidate(false): on-curve AND in-subgroup.
//
// CIRCL provides no public API to MINT an on-curve-but-not-in-subgroup G2 point
// (Hash/Encode both clear the cofactor; SetBytes only admits subgroup points),
// so the subgroup leg cannot be exercised by feeding such a point — it is
// verified by construction in the library and by source review. This test pins
// the BEHAVIORAL change the fix makes: a structurally-invalid non-zero blob
// that the old byte-loop ACCEPTED is now REJECTED, while real signatures still
// round-trip and the all-zero input stays rejected.
func TestSignatureFromBytes_RejectsNonG2_PuregoParity(t *testing.T) {
// 1) A real signature MUST still deserialize and round-trip unchanged.
sk, err := NewSecretKey()
if err != nil {
t.Fatalf("NewSecretKey: %v", err)
}
realSig, err := sk.Sign([]byte("lux/chain/vote/v1"))
if err != nil {
t.Fatalf("Sign: %v", err)
}
realBytes := SignatureToBytes(realSig)
got, err := SignatureFromBytes(realBytes)
if err != nil {
t.Fatalf("SignatureFromBytes(real) rejected a valid signature: %v", err)
}
if !bytes.Equal(SignatureToBytes(got), realBytes) {
t.Fatal("valid signature did not round-trip through SignatureFromBytes")
}
// Sanity: the real bytes are a valid G2 subgroup element (what SetBytes admits).
var realPt bls12381.G2
if err := realPt.SetBytes(realBytes); err != nil {
t.Fatalf("real signature bytes are not a valid G2 point: %v", err)
}
if !realPt.IsOnG2() {
t.Fatal("real signature point failed IsOnG2 (on-curve + r-torsion) sanity check")
}
// 2) The exact blob the OLD purego loop accepted (length 96, non-zero) but
// that is NOT a valid G2 encoding MUST now be rejected. byte[0]=0x01 has
// the compression bit clear, so SetBytes takes the uncompressed path and
// rejects on length (96 < 192). Deterministic. Without the fix this blob
// deserialized to a "Signature" — a garbage-signature forgery vector.
garbage := make([]byte, SignatureLen)
for i := range garbage {
garbage[i] = byte(i + 1)
}
// Prove the premise: a non-G2 blob the old code would have waved through.
var probe bls12381.G2
if probe.SetBytes(garbage) == nil {
t.Fatal("test premise broken: garbage blob unexpectedly decodes as a valid G2 point")
}
if _, err := SignatureFromBytes(garbage); err == nil {
t.Fatal("SignatureFromBytes accepted a non-G2 garbage blob (no point validation — the bug)")
}
// 3) An input whose top-byte carries an INVALID encoding prefix (0x20/0x60/
// 0xE0 per the BLS12-381 ZCash serialization) must be rejected outright.
badPrefix := make([]byte, SignatureLen)
badPrefix[0] = 0x20 // reserved/invalid compression-flag combination
badPrefix[1] = 0x42 // ensure non-zero so this is distinct from the all-zero case
if _, err := SignatureFromBytes(badPrefix); err == nil {
t.Fatal("SignatureFromBytes accepted an input with an invalid G2 serialization prefix")
}
// 4) Preserved behavior: all-zero input is still rejected (it is not a valid
// non-infinity encoding, and not the canonical compressed-infinity form).
if _, err := SignatureFromBytes(make([]byte, SignatureLen)); err == nil {
t.Fatal("SignatureFromBytes accepted an all-zero input")
}
// 5) Preserved behavior: wrong length is rejected before any point parse.
if _, err := SignatureFromBytes(make([]byte, SignatureLen-1)); err == nil {
t.Fatal("SignatureFromBytes accepted a wrong-length input")
}
}