BLS Signatures
Pairing-based signatures on BLS12-381 with aggregation, multi-signatures, proofs of possession, and non-interactive threshold key generation.
signatures/bls/bls_sig implements the BLS signature scheme from
draft-irtf-cfrg-bls-signature-03 on
BLS12-381. Its defining property is aggregation: any number of signatures can be combined into a
single group element that verifies against the corresponding set of public keys, and the combined
object is exactly the size of one signature.
Reach for BLS when you need to compress many signatures (block attestations, multi-party approvals,
certificate chains), or when you want t-of-n threshold signing without an interactive
protocol — BLS partial signatures combine by plain Lagrange interpolation, so signers never talk to
each other. Reach for something else if you need short verification time on constrained hardware
(pairings are expensive), or if your verifier is a chain that only knows secp256k1 or Ed25519 — in
that case see threshold ECDSA or
threshold Ed25519.
import "github.com/sonr-io/crypto/signatures/bls/bls_sig"
Two instantiations: Vt and non-Vt
BLS12-381 has two source groups, G1 and G2, and the pairing is asymmetric. You must decide which
group carries public keys and which carries signatures; whichever you put in G1 is the small one.
The package exposes both choices as two parallel type families that share a SecretKey type.
Non-Vt types |
Vt types |
|
|---|---|---|
| Public key group | G1 (PublicKey) |
G2 (PublicKeyVt) |
| Signature group | G2 (Signature) |
G1 (SignatureVt) |
| Compressed public key | 48 bytes (PublicKeySize) |
96 bytes (PublicKeyVtSize) |
| Compressed signature | 96 bytes (SignatureSize) |
48 bytes (SignatureVtSize) |
| Compressed PoP | 96 bytes (ProofOfPossessionSize) |
48 bytes (ProofOfPossessionVtSize) |
| Trade-off | minimal public key size | minimal signature size |
Secret keys are shared between the two families:
| Constant | Value | Meaning |
|---|---|---|
SecretKeySize |
32 |
A scalar mod r, the subgroup order. Cannot be zero. |
SecretKeyShareSize |
33 |
A 32-byte share value followed by a 1-byte identifier at index 32. |
SecretKeyShareSize being 33 rather than 32 is why shares are self-describing: the trailing
identifier is the Shamir x-coordinate, so CombineSignatures can reconstruct the Lagrange
coefficients from the partials alone. It also caps you at 255 shares — identifier 0 is invalid.
Which one do you want? If your verifier stores many public keys and sees few signatures (an on-chain
validator registry), the non-Vt family is cheaper. If you publish many signatures against few keys
(per-block attestations), Vt is cheaper. Ethereum 2 uses the non-Vt layout — 48-byte pubkeys in
G1, 96-byte signatures in G2 — which is what NewSigEth2() gives you.
Three ciphersuites
Independently of the group choice, the draft defines three ciphersuites that differ only in what gets hashed and what the caller must check. Each is a distinct Go type with its own constructor and its own domain separation tag.
| Scheme | Constructor | Signature DST | Extra requirement |
|---|---|---|---|
SigBasic |
NewSigBasic() |
BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_ |
All messages in an aggregate must be distinct |
SigAug |
NewSigAug() |
BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_ |
Public key is prepended to the message before hashing |
SigPop |
NewSigPop() |
BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_ |
Every key needs a verified proof of possession |
SigBasicVt |
NewSigBasicVt() |
BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_ |
as above |
SigAugVt |
NewSigAugVt() |
BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_AUG_ |
as above |
SigPopVt |
NewSigPopVt() |
BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_ |
as above |
SigPop additionally carries a second DST used only for proof-of-possession proofs:
| Constant | Value |
|---|---|
PoP proof DST (non-Vt) |
BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_ |
PoP proof DST (Vt) |
BLS_POP_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_ |
The G1/G2 token inside each DST names the group the signature lives in, which is why the Vt
tags say G1.
SigEth2 is a plain Go type alias for SigPop, and SigEth2Vt for SigPopVt:
type SigEth2 = SigPop
func NewSigEth2() *SigEth2 { return NewSigPop() }
They are naming conveniences, nothing more — NewSigEth2() and NewSigPop() return identical
values with identical DSTs.
Overriding the DST
Every scheme has a WithDst constructor for interoperating with a system that chose different
domain separation:
b := bls_sig.NewSigBasicWithDst("MY_APP_BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_")
// SigPop needs both tags, and rejects equal ones.
p, err := bls_sig.NewSigPopWithDst(
"MY_APP_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_",
"MY_APP_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_",
)
NewSigPopWithDst / NewSigPopVtWithDst are the only DST constructors that return an error: they
reject a signature DST equal to the PoP DST. The others accept any string, including an empty one.
What each ciphersuite defends against
The threat is the rogue-key attack. Aggregate verification checks a product of pairings. An
attacker who is allowed to publish a public key after seeing honest keys can publish
pk_evil = g^a · (Π pk_honest)^-1 and then produce an “aggregate” signature over a message the
honest parties never signed. The three ciphersuites each break this differently:
Nothing binds a key to its message beyond the message itself, so security rests on the caller
ensuring every message in an aggregate is distinct. AggregateVerify enforces this: it
rejects the batch if any two message byte strings are equal. Use Basic only when your messages
are naturally unique (they embed a nonce, a height, a hash).
Sign prepends the signer’s own compressed public key to the message before hashing:
H(pk_bytes || msg). That makes each signer’s hashed point key-dependent, so rogue keys cannot
cancel. Verify and AggregateVerify reproduce the same prefix. No caller discipline is
required, and messages may repeat. The cost is that verification needs the exact public key
bytes, and SigAug.PartialSign therefore takes an extra *PublicKey argument that the other
schemes do not.
Each signer publishes a proof of possession — a signature over their own public key under a
separate DST — proving they know the secret behind the key. Once every key in a set has a
verified PoP, rogue keys are impossible by construction, and the fast path opens up:
FastAggregateVerify and VerifyMultiSignature verify N signatures over the same message
with a single pairing check. This is the Eth2 configuration.
Method set
All six scheme types share this core. Signatures are (bool, error) — check both, because a
verification that errored also returns false, and a nil error does not mean valid.
Keygen()?(*PublicKey, *SecretKey, error)
Reads 32 bytes from crypto/rand and derives a keypair.
(*PublicKey, *SecretKey, error)KeygenWithSeed(ikm []byte)?(*PublicKey, *SecretKey, error)
Deterministic keygen via HKDF with salt "BLS-SIG-KEYGEN-SALT-". ikm MUST be at least 32 bytes; shorter input is an error.
(*PublicKey, *SecretKey, error)Sign(sk, msg)?(*Signature, error)
Hashes msg to a point and multiplies by the secret. Deterministic — no nonce, so no nonce-reuse failure mode. Basic and Pop accept an empty (but not nil) message; Aug rejects both.
(*Signature, error)Verify(pk, msg, sig)?(bool, error)
Single-signature verification.
(bool, error)AggregateVerify(pks, msgs, sigs)?(bool, error)
Aggregates sigs internally, then checks the product of pairings against every (pk, msg) pair. Errors on length mismatch. Basic and Pop reject duplicate messages.
(bool, error)ThresholdKeygen(threshold, total uint)?(*PublicKey, []*SecretKeyShare, error)
Generates one public key and `total` Shamir shares of its secret. Errors when threshold is 0, threshold exceeds total, total is 1 or less, or either exceeds 255.
(*PublicKey, []*SecretKeyShare, error)ThresholdKeygenWithSeed(ikm, threshold, total)?(*PublicKey, []*SecretKeyShare, error)
Same, seeded deterministically.
(*PublicKey, []*SecretKeyShare, error)PartialSign(sks, msg)?(*PartialSignature, error)
One share's contribution. Rejects nil and empty messages in every scheme. SigAug and SigAugVt take an extra *PublicKey between the share and the message.
(*PartialSignature, error)CombineSignatures(sigs ...*PartialSignature)?(*Signature, error)
Lagrange-interpolates partials into a normal signature. Errors on fewer than 2 partials, more than 255, a nil partial, a duplicate share identifier, or a partial outside the correct subgroup. It does NOT know your threshold — see the caveats.
(*Signature, error)SigPop and SigPopVt add:
PopProve(sk)?(*ProofOfPossession, error)
Signs the key's own public key under the PoP DST.
(*ProofOfPossession, error)PopVerify(pk, pop)?(bool, error)
Checks a proof of possession. Run this before trusting a key in any aggregate.
(bool, error)AggregatePublicKeys(pks ...*PublicKey)?(*MultiPublicKey, error)
Sums public keys into a single group element for same-message verification.
(*MultiPublicKey, error)AggregateSignatures(sigs ...*Signature)?(*MultiSignature, error)
Sums signatures over the same message.
(*MultiSignature, error)VerifyMultiSignature(mpk, msg, msig)?(bool, error)
Verifies a pre-aggregated key against a pre-aggregated signature. One pairing check.
(bool, error)FastAggregateVerify(pks, msg, asig)?(bool, error)
Same-message verification where the signature is already aggregated but the keys are not.
(bool, error)FastAggregateVerifyConstituent(pks, msg, sigs)?(bool, error)
Same, but takes the individual signatures and aggregates them for you.
(bool, error)AggregateVerify (many distinct messages) and FastAggregateVerify (one shared message) are not
interchangeable. Passing the same message N times to AggregateVerify under SigBasic or SigPop
returns false by design.
Aggregate verification
Grounded in TestBasicAggregateVerifyG2Works and its generateBasicAggregateDataG2 helper.
package main
import (
"crypto/rand"
"fmt"
"log"
"github.com/sonr-io/crypto/signatures/bls/bls_sig"
)
func main() {
bls := bls_sig.NewSigBasic()
const n = 10
pks := make([]*bls_sig.PublicKey, n)
sigs := make([]*bls_sig.Signature, n)
msgs := make([][]byte, n)
for i := 0; i < n; i++ {
ikm := make([]byte, 32)
if _, err := rand.Read(ikm); err != nil {
log.Fatal(err)
}
pk, sk, err := bls.KeygenWithSeed(ikm)
if err != nil {
log.Fatal(err)
}
// SigBasic requires every message in the batch to differ.
msg := []byte(fmt.Sprintf("attestation %d", i))
sig, err := bls.Sign(sk, msg)
if err != nil {
log.Fatal(err)
}
pks[i], sigs[i], msgs[i] = pk, sig, msg
}
ok, err := bls.AggregateVerify(pks, msgs, sigs)
if err != nil {
log.Fatal(err)
}
fmt.Println("aggregate valid:", ok)
}
Swap NewSigBasic() for NewSigAug() and the duplicate-message restriction disappears, at the cost
of PartialSign gaining a public-key argument.
Threshold signing
Grounded in TestBasicPartialSign. Note there is no DKG here and no interaction between signers:
ThresholdKeygen produces the shares centrally, and each holder signs independently.
Deal the shares
ThresholdKeygen(2, 4) returns one public key plus four *SecretKeyShare values. The public
key is the ordinary BLS public key for the reconstructed secret — verifiers never learn that
threshold signing happened.
Sign independently
Each holder calls PartialSign(share, msg). No round trips, no shared state, no per-signature
nonce. Partials can be produced years apart.
Combine
CombineSignatures(partials...) Lagrange-interpolates in the exponent. It rejects fewer than
two partials, duplicate share identifiers, and nil entries — but it has no idea what your
threshold was, so short-of-threshold input succeeds and yields a wrong signature.
Verify normally
The result is an ordinary *Signature. Verify(pk, msg, sig) accepts it.
package main
import (
"fmt"
"log"
"github.com/sonr-io/crypto/signatures/bls/bls_sig"
)
func main() {
bls := bls_sig.NewSigBasic()
// 2-of-4. pk is the ordinary public key for the (never assembled) secret.
pk, shares, err := bls.ThresholdKeygen(2, 4)
if err != nil {
log.Fatal(err)
}
msg := []byte("release the funds")
p1, err := bls.PartialSign(shares[0], msg)
if err != nil {
log.Fatal(err)
}
p2, err := bls.PartialSign(shares[2], msg)
if err != nil {
log.Fatal(err)
}
sig, err := bls.CombineSignatures(p1, p2)
if err != nil {
log.Fatal(err)
}
ok, err := bls.Verify(pk, msg, sig)
if err != nil {
log.Fatal(err)
}
fmt.Println("threshold signature valid:", ok) // true
}
PartialSignature is the only public-field type in the package:
type PartialSignature struct {
Identifier byte
Signature bls12381.G2 // bls12381.G1 for PartialSignatureVt
}
Partials are not BinaryMarshalers — if you need to ship them across a wire, serialize the
identifier and the group element yourself.
Serialization
Every key, signature, PoP, multi-key, multi-signature, and secret-key share implements
encoding.BinaryMarshaler and encoding.BinaryUnmarshaler, using the standard compressed
zcash BLS12-381 encoding.
The unmarshalers validate length, reject the all-zero encoding, and check subgroup membership.
raw, err := pk.MarshalBinary() // 48 bytes for PublicKey, 96 for PublicKeyVt
var restored bls_sig.PublicKey
err = restored.UnmarshalBinary(raw)
SecretKey.UnmarshalBinary requires exactly 32 bytes and rejects all-zero input.
SecretKeyShare.UnmarshalBinary requires exactly 33 and likewise rejects all-zero; the identifier
is the final byte.
Caveats
Related
Secret sharing
Shamir, Feldman, and Pedersen sharing — the general machinery behind ThresholdKeygen.
Distributed key generation
When no single party may ever hold the whole secret, even at dealing time.
Accumulator
The other pairing-based primitive in this library, also on BLS12-381.
Security notes
Known defects and unaudited paths across the library.