mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
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).
74 lines
3.1 KiB
Go
74 lines
3.1 KiB
Go
// 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 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:
|
|
// - 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")
|
|
}
|
|
}
|