Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

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.

PropType
Keygen()?(*PublicKey, *SecretKey, error)

Reads 32 bytes from crypto/rand and derives a keypair.

Type(*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.

Type(*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.

Type(*Signature, error)
Verify(pk, msg, sig)?(bool, error)

Single-signature verification.

Type(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.

Type(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.

Type(*PublicKey, []*SecretKeyShare, error)
ThresholdKeygenWithSeed(ikm, threshold, total)?(*PublicKey, []*SecretKeyShare, error)

Same, seeded deterministically.

Type(*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.

Type(*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.

Type(*Signature, error)

SigPop and SigPopVt add:

PropType
PopProve(sk)?(*ProofOfPossession, error)

Signs the key's own public key under the PoP DST.

Type(*ProofOfPossession, error)
PopVerify(pk, pop)?(bool, error)

Checks a proof of possession. Run this before trusting a key in any aggregate.

Type(bool, error)
AggregatePublicKeys(pks ...*PublicKey)?(*MultiPublicKey, error)

Sums public keys into a single group element for same-message verification.

Type(*MultiPublicKey, error)
AggregateSignatures(sigs ...*Signature)?(*MultiSignature, error)

Sums signatures over the same message.

Type(*MultiSignature, error)
VerifyMultiSignature(mpk, msg, msig)?(bool, error)

Verifies a pre-aggregated key against a pre-aggregated signature. One pairing check.

Type(bool, error)
FastAggregateVerify(pks, msg, asig)?(bool, error)

Same-message verification where the signature is already aggregated but the keys are not.

Type(bool, error)
FastAggregateVerifyConstituent(pks, msg, sigs)?(bool, error)

Same, but takes the individual signatures and aggregates them for you.

Type(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

Last updated on September 2, 2026

Was this page helpful?