mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
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:
+17
-5
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user