bls: harden the deserialization + aggregation boundary (HIGH-1, RESIDUAL-C, identity-aggregate)

Three additive, fail-closed hardenings on the BLS boundary, with the security-
critical regressions on the purego (CIRCL, //go:build !cgo) path — the canonical
CGO_ENABLED=0 node image:

HIGH-1 (panic-DoS). CIRCL's bls12381 G1/G2 SetBytes accepts the MALFORMED
encoding 'infinity bit set, compression bit clear' (top byte b[0]&0xC0 == 0x40):
it computes the UNCOMPRESSED length and slices b[1:96]/b[1:192] on a canonical
48/96-byte COMPRESSED buffer -> slice-bounds-out-of-range PANIC. On a purego node a
single 0x40||zeros blob in a proof-of-possession / peer-handshake / warp / quasar
BLS field is an unauthenticated, consensus-halting DoS. Reject b[0]&0xC0 == 0x40 at
the compressed length BEFORE SetBytes, in-band (not recover()), restoring parity
with blst's erroring Uncompress. Guards: PublicKeyFromCompressedBytes,
SignatureFromBytes. Regression: sig_infinity_unset_compression_test.go.

RESIDUAL-C (identity at one boundary). Move identity rejection to the SINGLE
deserialization boundary: PublicKeyFromCompressedBytes / FromValidUncompressedBytes
call Validate()/KeyValidate() (= !IsIdentity() && on-curve && in-subgroup), so an
identity (or off-curve) key never escapes a constructor. Verify/VerifyProofOfPossession
drop their redundant per-call identity test — which was WRONG anyway (it compared
against 0x00||zeros; the canonical compressed-G1 infinity is 0xc0||zeros, so it
never matched). FromValidUncompressedBytes now actually validates (was a silent
_ = UnmarshalBinary).

Identity-aggregate (defence in depth). AggregatePublicKeys now rejects an aggregate
that is the IDENTITY (point at infinity). Each input is a valid non-identity
subgroup key, and the subgroup is closed under addition — but the sum can still be
O when inputs sum to zero (the rogue-key shape pk + (-pk) = O). An identity
aggregate public key makes Verify trivially accept the identity signature (a forgery
enabler). PoP at registration already blocks an attacker contributing an
unpossessed key, but the verifier must not depend on that everywhere: purego
result.Validate() and cgo out.KeyValidate() fail the aggregate closed. Regression
(agg_identity_reject_test.go): pk + -pk must error; proven to FAIL without the guard
(returns a usable identity key); a legitimate two-key aggregate still succeeds.

Full bls suite green on both backends (CGO_ENABLED=0 and =1).
This commit is contained in:
zeekay
2026-06-22 22:26:49 -07:00
parent 20ad50cf9d
commit 9a71af427f
5 changed files with 307 additions and 58 deletions
+88
View File
@@ -0,0 +1,88 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package bls
import (
"testing"
"github.com/cloudflare/circl/ecc/bls12381"
blssign "github.com/cloudflare/circl/sign/bls"
)
// negatePublicKey returns -pk as a *PublicKey by negating the underlying G1 point
// (white-box: the test is package bls). For BLS12-381, -P = (x, -y); CIRCL's
// bls12381.G1.Neg() negates y. The negated key is a VALID non-identity subgroup
// point (it is the public key of -sk), so it passes every input check — yet
// aggregating it with pk yields the identity, the case under test.
func negatePublicKey(t *testing.T, pk *PublicKey) *PublicKey {
t.Helper()
b, err := pk.pk.MarshalBinary()
if err != nil {
t.Fatalf("marshal pk: %v", err)
}
var g bls12381.G1
if err := g.SetBytes(b); err != nil {
t.Fatalf("decode pk point: %v", err)
}
g.Neg()
neg := new(blssign.PublicKey[blssign.KeyG1SigG2])
if err := neg.UnmarshalBinary(g.BytesCompressed()); err != nil {
t.Fatalf("re-encode negated point: %v", err)
}
// Sanity: -pk is itself a valid (non-identity, on-curve, in-subgroup) key.
if !neg.Validate() {
t.Fatal("negated key must itself be a valid non-identity G1 point")
}
return &PublicKey{pk: neg}
}
// TestAggregatePublicKeys_RejectsIdentityAggregate is the defense-in-depth
// regression: AggregatePublicKeys must REFUSE an aggregate that is the identity
// (point at infinity), even when every input is a valid non-identity subgroup key.
//
// Construction: pk and -pk. Both are valid keys (each is the public key of a real
// secret, -sk for the second), so they pass every per-input check; their sum is
// the identity O. WITHOUT the result.Validate() guard, AggregatePublicKeys would
// return a usable *PublicKey wrapping O — and Verify against the identity key
// trivially accepts the identity signature, a forgery enabler. WITH the guard, it
// returns a clean error.
//
// purego (CIRCL, //go:build !cgo) is the canonical node image (CGO_ENABLED=0); the
// cgo path enforces the SAME guard via blst KeyValidate().
func TestAggregatePublicKeys_RejectsIdentityAggregate(t *testing.T) {
sk, err := NewSecretKey()
if err != nil {
t.Fatalf("NewSecretKey: %v", err)
}
pk := sk.PublicKey()
negPk := negatePublicKey(t, pk)
// Both inputs are valid keys (precondition for the test to be meaningful: the
// reject must come from the AGGREGATE being identity, not from a bad input).
if !pk.pk.Validate() || !negPk.pk.Validate() {
t.Fatal("both inputs must be valid non-identity keys")
}
agg, err := AggregatePublicKeys([]*PublicKey{pk, negPk})
if err == nil {
t.Fatalf("AggregatePublicKeys accepted an IDENTITY aggregate (pk + -pk = O) — "+
"it must fail closed; got key=%v", agg)
}
if agg != nil {
t.Fatal("AggregatePublicKeys must return a nil key alongside the error for an identity aggregate")
}
// Control: a normal two-key aggregate (pk + pk' with independent keys) is NOT
// the identity and MUST still succeed — the guard rejects only the degenerate
// identity, never a legitimate aggregate.
sk2, err := NewSecretKey()
if err != nil {
t.Fatalf("NewSecretKey 2: %v", err)
}
if _, err := AggregatePublicKeys([]*PublicKey{pk, sk2.PublicKey()}); err != nil {
t.Fatalf("a legitimate (non-identity) aggregate must succeed, got: %v", err)
}
}
+62 -30
View File
@@ -145,6 +145,23 @@ func PublicKeyToCompressedBytes(pk *PublicKey) []byte {
}
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
@@ -161,7 +178,16 @@ func PublicKeyToUncompressedBytes(key *PublicKey) []byte {
func PublicKeyFromValidUncompressedBytes(pkBytes []byte) *PublicKey {
pk := new(blssign.PublicKey[blssign.KeyG1SigG2])
_ = pk.UnmarshalBinary(pkBytes)
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}
}
@@ -192,36 +218,32 @@ func AggregatePublicKeys(pks []*PublicKey) (*PublicKey, error) {
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
}
// isIdentityG1 checks if a public key is the identity point (point at infinity).
// Returns true if the key is the identity, false otherwise.
func isIdentityG1(pk *blssign.PublicKey[blssign.KeyG1SigG2]) bool {
// Serialize the public key and check if it's all zeros (compressed identity)
pkBytes, err := pk.MarshalBinary()
if err != nil {
return false
}
// BLS12-381 G1 compressed identity point is a specific encoding
// Check if it matches the identity point encoding
for _, b := range pkBytes {
if b != 0 {
return false
}
}
return true
}
func Verify(pk *PublicKey, sig *Signature, msg []byte) bool {
if pk == nil || pk.pk == nil || sig == nil {
return false
}
// Check that public key is not the identity point (zero-key)
// Identity point verification would trivially pass for any signature
if isIdentityG1(pk.pk) {
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)
}
@@ -231,10 +253,7 @@ func VerifyProofOfPossession(pk *PublicKey, sig *Signature, msg []byte) bool {
if pk == nil || pk.pk == nil || sig == nil {
return false
}
// Check that public key is not the identity point (zero-key)
if isIdentityG1(pk.pk) {
return false
}
// Identity (zero) pubkey already rejected at decode (Validate); see Verify.
// Parse the signature as a G2 point
var sigPoint bls12381.G2
@@ -287,6 +306,19 @@ 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
@@ -301,8 +333,8 @@ func SignatureFromBytes(sigBytes []byte) (*Signature, error) {
if err := g.SetBytes(sigBytes); err != nil {
return nil, ErrFailedSignatureDecompress
}
// Reject the G2 identity (point at infinity) — symmetric with the isIdentityG1
// pubkey guard in Verify (INFO-4). CIRCL's SetBytes accepts a well-formed
// 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
+33 -26
View File
@@ -162,7 +162,16 @@ func PublicKeyToUncompressedBytes(key *PublicKey) []byte {
// are valid.
func PublicKeyFromValidUncompressedBytes(pkBytes []byte) *PublicKey {
pk := new(blst.P1Affine)
pk.Deserialize(pkBytes)
if pk.Deserialize(pkBytes) == nil {
return nil
}
// Identity rejection lives at the ONE deserialization boundary (RESIDUAL C):
// KeyValidate() rejects the infinity point (and off-curve / wrong-subgroup),
// so an identity key never escapes a constructor. Downstream Verify therefore
// does NOT re-check for the identity point — the check belongs here, once.
if !pk.KeyValidate() {
return nil
}
return &PublicKey{pk: pk}
}
@@ -183,22 +192,20 @@ func AggregatePublicKeys(pks []*PublicKey) (*PublicKey, error) {
}
}
return &PublicKey{pk: agg.ToAffine()}, nil
// 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 KeyValidate), 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).
// blst's KeyValidate rejects the infinity point (and off-curve / wrong-subgroup),
// matching the purego path's result.Validate() — fail the aggregate closed rather
// than depend on proof-of-possession being enforced upstream everywhere.
out := agg.ToAffine()
if !out.KeyValidate() {
return nil, ErrFailedPublicKeyAggregation
}
// isIdentityG1 checks if a public key is the identity point (point at infinity).
// Returns true if the key is the identity, false otherwise.
func isIdentityG1(pk *blst.P1Affine) bool {
// Check if the point is at infinity using blst's built-in check
// The identity point in G1 is represented as all zeros in compressed form
pkBytes := pk.Compress()
// Check for all-zero bytes (identity point encoding)
for _, b := range pkBytes {
if b != 0 {
return false
}
}
return true
return &PublicKey{pk: out}, nil
}
// Verify the [sig] of [msg] against the [pk].
@@ -206,11 +213,13 @@ func Verify(pk *PublicKey, sig *Signature, msg []byte) bool {
if pk == nil || pk.pk == nil || sig == nil || sig.sig == nil {
return false
}
// Check that public key is not the identity point (zero-key)
// Identity point verification would trivially pass for any signature
if isIdentityG1(pk.pk) {
return false
}
// The identity (zero) public key is rejected at the deserialization boundary
// (PublicKeyFromCompressedBytes / PublicKeyFromValidUncompressedBytes call
// KeyValidate(), which rejects the infinity point), 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 (blst Compress()
// of the identity is 0xc0||zeros, not 0x00||zeros, so it never actually
// matched) — removed (RESIDUAL C): identity rejection belongs at decode, once.
return sig.sig.Verify(true, pk.pk, false, msg, dstSignature)
}
@@ -219,10 +228,7 @@ func VerifyProofOfPossession(pk *PublicKey, sig *Signature, msg []byte) bool {
if pk == nil || pk.pk == nil || sig == nil || sig.sig == nil {
return false
}
// Check that public key is not the identity point (zero-key)
if isIdentityG1(pk.pk) {
return false
}
// Identity (zero) pubkey already rejected at decode (KeyValidate); see Verify.
return sig.sig.Verify(true, pk.pk, false, msg, dstPoP)
}
@@ -252,7 +258,8 @@ func SignatureFromBytes(sigBytes []byte) (*Signature, error) {
// `if is_inf(p) return false; return in_g2(p)`. The prior SigValidate(false)
// skipped the infinity check, accepting the identity signature (INFO-4); this
// makes the blst path symmetric with the purego SignatureFromBytes identity
// reject and with the isIdentityG1 pubkey guard. The identity sig does not forge
// reject and with the pubkey identity guard at decode (KeyValidate). The
// identity sig does not forge
// against a real key, but admitting a degenerate point into the verifier is an
// asymmetry worth closing.
if !sig.SigValidate(true) {
+4 -2
View File
@@ -2,8 +2,10 @@
// See the file LICENSE for licensing terms.
// sig_identity_reject_test.go — INFO-4 regression: SignatureFromBytes must reject
// the G2 identity (point at infinity), symmetric with the isIdentityG1 pubkey
// guard in Verify. The canonical compressed G2 identity is 0xc0 || 95 zero bytes
// the G2 identity (point at infinity), symmetric with the pubkey identity guard.
// The pubkey side rejects identity at the deserialization boundary
// (PublicKeyFrom*Bytes → Validate()/KeyValidate()), so this pins the matching
// signature-side reject. The canonical compressed G2 identity is 0xc0 || 95 zero bytes
// (compression bit + infinity bit set; all coordinate bytes zero — see blst
// e1.c:205 "compressed and infinity bits"). It is a WELL-FORMED encoding that
// both deserializers used to accept:
+120
View File
@@ -0,0 +1,120 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build !cgo
package bls
import "testing"
// TestBLS_HIGH1_InfinityBitSet_CompressionClear is the regression guard for the
// purego (CIRCL, //go:build !cgo) unauthenticated panic-DoS (HIGH-1).
//
// CIRCL's bls12381 G1/G2 SetBytes accepts the MALFORMED encoding where the
// infinity bit (0x40) is set but the compression bit (0x80) is CLEAR — top byte
// b[0]&0xC0 == 0x40. In that branch it computes the UNCOMPRESSED length
// (G1Size=96 / G2Size=192) and slices b[1:l] on what is actually a CANONICAL
// compressed buffer (PublicKeyLen=48 / SignatureLen=96):
//
// ecc/bls12381/g1.go: if isInfinity==1 { l := G1Size; ... b[1:l] ... } // l=96 on a 48-byte buf
// ecc/bls12381/g2.go: if isInfinity==1 { l := G2Size; ... b[1:l] ... } // l=192 on a 96-byte buf
//
// → "slice bounds out of range" runtime PANIC. The canonical node image is
// CGO_ENABLED=0 (purego), so a single `0x40 || zeros` blob in a proof-of-
// possession, peer-handshake, or warp/quasar BLS field CRASHES every node — an
// unauthenticated, consensus-halting DoS.
//
// The fix rejects b[0]&0xC0 == 0x40 at the compressed length BEFORE SetBytes,
// as an in-band error (NOT a recover()): a precise input rejection that matches
// the CGO/blst path (whose Uncompress returns an error, never panics). This test
// asserts NO PANIC and a clean error for the exact attack input at both lengths.
func TestBLS_HIGH1_InfinityBitSet_CompressionClear(t *testing.T) {
// G1 / public key: 0x40 || zeros at the canonical compressed length (48).
t.Run("PublicKeyFromCompressedBytes/G1", func(t *testing.T) {
b := make([]byte, PublicKeyLen)
b[0] = 0x40 // infinity bit set, compression bit clear
// MUST NOT panic; MUST return a clean decompress error.
pk, err := PublicKeyFromCompressedBytes(b)
if err == nil {
t.Fatal("PublicKeyFromCompressedBytes accepted 0x40||zeros (must reject)")
}
if pk != nil {
t.Fatal("PublicKeyFromCompressedBytes returned a non-nil key for a rejected input")
}
})
// G2 / signature: 0x40 || zeros at the canonical compressed length (96).
t.Run("SignatureFromBytes/G2", func(t *testing.T) {
b := make([]byte, SignatureLen)
b[0] = 0x40
sig, err := SignatureFromBytes(b)
if err == nil {
t.Fatal("SignatureFromBytes accepted 0x40||zeros (must reject)")
}
if sig != nil {
t.Fatal("SignatureFromBytes returned a non-nil signature for a rejected input")
}
})
// The reject must be on the (infinity-set, compression-clear) bit pattern,
// independent of the trailing bytes — a single high byte is enough to brick a
// node, with or without payload after it. Non-zero tail must ALSO be rejected
// (and must not panic).
t.Run("PublicKey/G1/nonzero-tail", func(t *testing.T) {
b := make([]byte, PublicKeyLen)
b[0] = 0x40
b[1] = 0xFF
if _, err := PublicKeyFromCompressedBytes(b); err == nil {
t.Fatal("PublicKeyFromCompressedBytes accepted 0x40 with non-zero tail")
}
})
t.Run("Signature/G2/nonzero-tail", func(t *testing.T) {
b := make([]byte, SignatureLen)
b[0] = 0x40
b[1] = 0xFF
if _, err := SignatureFromBytes(b); err == nil {
t.Fatal("SignatureFromBytes accepted 0x40 with non-zero tail")
}
})
// The full malformed-infinity family (compression clear, infinity set, any of
// the low 5 flag/sign bits) must be rejected at both lengths without panic.
// These are exactly the b[0] values that drove CIRCL into the uncompressed-
// length branch on a compressed buffer.
t.Run("flag-family/no-panic", func(t *testing.T) {
for _, hi := range []byte{0x40, 0x41, 0x50, 0x60, 0x7F} {
pkb := make([]byte, PublicKeyLen)
pkb[0] = hi
if _, err := PublicKeyFromCompressedBytes(pkb); err == nil {
t.Fatalf("PublicKeyFromCompressedBytes accepted malformed prefix 0x%02x", hi)
}
sgb := make([]byte, SignatureLen)
sgb[0] = hi
if _, err := SignatureFromBytes(sgb); err == nil {
t.Fatalf("SignatureFromBytes accepted malformed prefix 0x%02x", hi)
}
}
})
}
// TestBLS_HIGH1_CanonicalInfinityStillRejected pins that the fix does NOT change
// the handling of the CANONICAL compressed-infinity encoding (0xc0 || zeros):
// it remains rejected (identity is not a valid public key / signature), via the
// existing Validate()/IsIdentity() guards — the HIGH-1 length-prefix guard only
// targets the malformed (compression-clear) infinity form, leaving the canonical
// form to the identity checks. This proves the guard is additive, not a
// replacement that could let the canonical identity slip through.
func TestBLS_HIGH1_CanonicalInfinityStillRejected(t *testing.T) {
// 0xc0 = compression bit + infinity bit set (the canonical compressed
// point-at-infinity per the ZCash BLS12-381 serialization).
pkInf := make([]byte, PublicKeyLen)
pkInf[0] = 0xc0
if _, err := PublicKeyFromCompressedBytes(pkInf); err == nil {
t.Fatal("PublicKeyFromCompressedBytes accepted the canonical identity (0xc0||zeros)")
}
sigInf := make([]byte, SignatureLen)
sigInf[0] = 0xc0
if _, err := SignatureFromBytes(sigInf); err == nil {
t.Fatal("SignatureFromBytes accepted the canonical identity (0xc0||zeros)")
}
}