bls: reject G2 identity signature in SignatureFromBytes (INFO-4)

Symmetric with the isIdentityG1 pubkey guard in Verify. The canonical
compressed G2 identity (0xc0 || zeros) is a well-formed encoding both
deserializers accepted:
  - purego (CIRCL): G2.SetBytes returns at the isInfinity branch BEFORE
    IsOnG2 — add an explicit g.IsIdentity() reject.
  - blst: SigValidate(false) skipped the infinity check — switch to
    SigValidate(true), which is is_inf(p)==false && in_g2(p) (rejects
    infinity AND keeps the r-torsion subgroup check; strictly additive).

The identity sig does not forge against a real key, but admitting a
degenerate point into the verifier is an asymmetry worth closing.

TDD: TestSignatureFromBytes_RejectsIdentity — identity sig rejected; a
real sig still round-trips byte-identically and verifies. Proven to fail
without the guard.
This commit is contained in:
zeekay
2026-06-22 19:34:39 -07:00
parent be7dbb79d5
commit 20ad50cf9d
3 changed files with 90 additions and 1 deletions
+10
View File
@@ -301,6 +301,16 @@ 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
// 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).
+9 -1
View File
@@ -247,7 +247,15 @@ func SignatureFromBytes(sigBytes []byte) (*Signature, error) {
return nil, ErrFailedSignatureDecompress
}
if !sig.SigValidate(false) {
// SigValidate(true) checks BOTH r-torsion subgroup membership AND rejects the
// G2 identity (point at infinity) — go_p2_affine_validate(p, infcheck=true) is
// `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
// against a real key, but admitting a degenerate point into the verifier is an
// asymmetry worth closing.
if !sig.SigValidate(true) {
return nil, ErrInvalidSignature
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// 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
// (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:
// - purego (CIRCL): G2.SetBytes returns at the isInfinity branch BEFORE IsOnG2.
// - blst: SigValidate(false) skipped the infinity check.
// The fix rejects it on both paths (purego: explicit IsIdentity reject; blst:
// SigValidate(true) does is_inf + in_g2). This test is build-tag agnostic so it
// runs against whichever implementation is compiled in, asserting BOTH paths
// behave identically.
package bls
import (
"bytes"
"testing"
)
// compressedG2Identity is the canonical compressed encoding of the G2 point at
// infinity: 0xc0 (compression|infinity bits) followed by zeros for the whole
// SignatureLen-byte field. This is the exact blob INFO-4 was accepting.
func compressedG2Identity() []byte {
b := make([]byte, SignatureLen)
b[0] = 0xc0
return b
}
func TestSignatureFromBytes_RejectsIdentity(t *testing.T) {
// (1) The canonical compressed G2 identity MUST be rejected. Before the fix
// this deserialized cleanly (purego: returned at the infinity branch; blst:
// SigValidate(false) skipped the infinity check).
if sig, err := SignatureFromBytes(compressedG2Identity()); err == nil {
t.Fatalf("INFO-4: SignatureFromBytes accepted the G2 identity (point at infinity); "+
"got sig=%v, want rejection", sig)
}
// (2) A REAL signature still round-trips and verifies — the identity reject
// must not block valid signatures.
sk, err := NewSecretKey()
if err != nil {
t.Fatalf("NewSecretKey: %v", err)
}
msg := []byte("info-4 identity reject — real sig must survive")
sig, err := sk.Sign(msg)
if err != nil {
t.Fatalf("Sign: %v", err)
}
sigBytes := SignatureToBytes(sig)
roundTripped, err := SignatureFromBytes(sigBytes)
if err != nil {
t.Fatalf("SignatureFromBytes rejected a VALID signature: %v", err)
}
if !bytes.Equal(SignatureToBytes(roundTripped), sigBytes) {
t.Fatal("valid signature did not round-trip byte-identically through SignatureFromBytes")
}
if !Verify(sk.PublicKey(), roundTripped, msg) {
t.Fatal("round-tripped valid signature failed to verify against its key+message")
}
// (3) A real signature is NOT the identity — sanity that the guard is precise
// (it rejects only the degenerate point, not the first byte 0xc0 generically;
// real compressed G2 sigs may legitimately have the compression bit set).
if bytes.Equal(sigBytes, compressedG2Identity()) {
t.Fatal("a real signature must never equal the G2 identity encoding")
}
}