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

BBS+ Signatures

Sign a vector of attributes on BLS12-381, then prove possession of the signature while disclosing only the attributes you choose — plus blind signing so the issuer never sees part of what it signs.

signatures/bbs implements the BBS+ signature scheme from eprint 2016/663, section 4.3. A BBS+ signature covers an ordered vector of scalar messages rather than one byte string, and that is the entire point: the holder of a signature can later produce a zero-knowledge proof that says “an issuer I can name signed four attributes; here are attributes 3 and 4; I know the other two but I am not telling you”. The verifier learns nothing about the hidden attributes beyond the fact that they were signed.

This is the credential primitive. Reach for it when you are issuing something like a driver’s licence or a KYC attestation and the holder must be able to prove “over 21” to a bar without handing over a birth date, a licence number, and an address. Do not reach for it when you just need to sign a document — the machinery is heavy, verification runs pairings, and BLS or ECDSA does that job far more cheaply.

Requirements

BBS+ needs a pairing, so it needs a *curves.PairingCurve. In practice that means BLS12-381:

import (
	"github.com/sonr-io/crypto/core/curves"
	"github.com/sonr-io/crypto/signatures/bbs"
)

curve := curves.BLS12381(&curves.PointBls12381G2{})

The argument to curves.BLS12381 chooses which group holds the public key. Passing &curves.PointBls12381G2{} puts the key in G2 and signatures in G1 — the layout every test in the package uses. See the curve abstraction for what PairingCurve provides.

Messages are curves.Scalar, not bytes. Convert with curve.Scalar.Hash([]byte("...")) for free-form attributes, or curve.Scalar.New(n) for small integers.

Keys and generators

PropType
NewKeys(curve *curves.PairingCurve)?(*PublicKey, *SecretKey, error)

Generates a fresh keypair. Note the public key comes FIRST in the return order.

Type(*PublicKey, *SecretKey, error)
NewSecretKey(curve *curves.PairingCurve)?(*SecretKey, error)

Just the signing key.

Type(*SecretKey, error)
SecretKey.PublicKey()?*PublicKey

Derives the verification key. No error return.

Type*PublicKey
MessageGenerators.Init(w *PublicKey, length int)?(*MessageGenerators, error)

Derives `length` message generators plus the blinding generator h0, deterministically from the public key. Errors only on negative length.

Type(*MessageGenerators, error)
MessageGenerators.Get(i int)?curves.PairingPoint

i <= 0 returns h0, the blinding generator. 1..length return message generators. Out of range returns nil (not an error). Currently broken — see the danger callout.

Typecurves.PairingPoint

Generators are derived from the public key, not stored with it. That is what lets one key sign credentials of any width: you re-Init with a different length and get a different generator set. It also means the verifier must Init with exactly the same length the signer used, or every generator differs and nothing verifies.

Get is one-based for messages: message index i in your slice uses generator Get(i + 1), and Get(0) is the blinding generator h_0.

Signing and verifying a full vector

Grounded in TestSignatureWorks.

package main

import (
	"fmt"
	"log"

	"github.com/sonr-io/crypto/core/curves"
	"github.com/sonr-io/crypto/signatures/bbs"
)

func main() {
	curve := curves.BLS12381(&curves.PointBls12381G2{})

	pk, sk, err := bbs.NewKeys(curve)
	if err != nil {
		log.Fatal(err)
	}

	// One generator per attribute.
	generators, err := new(bbs.MessageGenerators).Init(pk, 4)
	if err != nil {
		log.Fatal(err)
	}

	msgs := []curves.Scalar{
		curve.Scalar.Hash([]byte("did:key:z6Mk...")),
		curve.Scalar.Hash([]byte("Ada")),
		curve.Scalar.Hash([]byte("Lovelace")),
		curve.Scalar.New(36),
	}

	sig, err := sk.Sign(generators, msgs)
	if err != nil {
		log.Fatal(err)
	}

	// Verify returns error, not bool. nil means valid.
	if err := pk.Verify(sig, generators, msgs); err != nil {
		log.Fatal("invalid signature: ", err)
	}
	fmt.Println("signature valid")
}

Sign is deterministic: the internal e and s scalars come from a SHAKE256 DRBG seeded with the secret key, the generators, and the messages. Signing the same vector twice with the same key produces byte-identical output. There is no io.Reader parameter and no nonce to misuse.

Sign errors on an empty message slice, on generators.length < len(msgs), and on a zero secret key. Verify additionally rejects an identity public key and an identity signature point.

Selective disclosure

This is the flow that makes BBS+ worth its cost. The holder turns their signature into a PokSignature, derives a Fiat-Shamir challenge from a merlin transcript, and emits a PokSignatureProof. The verifier rebuilds the same transcript from the proof and the messages it was shown, recomputes the challenge, and checks the two match.

Classify every message

Build a []common.ProofMessage with exactly one entry per generator, in signing order. Use common.RevealedMessage{Message: m} for attributes the verifier will see and common.ProofSpecificMessage{Message: m} for attributes it will not. Use common.SharedBlindingMessage{Message: m, Blinding: b} only when the same hidden value must be linked to another proof (a range proof over the same age, for example).

Commit

NewPokSignature(sig, generators, proofMsgs, reader) randomises the signature and builds the Schnorr commitments. The reader supplies the proof’s randomness — pass crand.Reader.

Derive the challenge

Create a merlin transcript with an application-specific label, feed it pok.GetChallengeContribution(transcript), append the verifier’s nonce, extract 64 bytes and reduce them with curve.Scalar.SetBytesWide.

Generate

pok.GenerateProof(challenge) converts the blinding factors into response scalars and returns the *PokSignatureProof. Send that, the challenge, the revealed messages, and the nonce.

Verify

The verifier calls pokSig.Verify(revealedMsgs, pk, generators, nonce, challenge, transcript) with a transcript constructed identically to the prover’s.

Grounded in TestPokSignatureProofSomeMessagesRevealed.

package main

import (
	crand "crypto/rand"
	"fmt"
	"log"

	"github.com/gtank/merlin"

	"github.com/sonr-io/crypto/core/curves"
	"github.com/sonr-io/crypto/signatures/bbs"
	"github.com/sonr-io/crypto/signatures/common"
)

const transcriptLabel = "example.com/credential-presentation/v1"

func main() {
	curve := curves.BLS12381(&curves.PointBls12381G2{})
	pk, sk, err := bbs.NewKeys(curve)
	if err != nil {
		log.Fatal(err)
	}
	generators, err := new(bbs.MessageGenerators).Init(pk, 4)
	if err != nil {
		log.Fatal(err)
	}

	msgs := []curves.Scalar{
		curve.Scalar.New(2), // holder id      — keep hidden
		curve.Scalar.New(3), // date of birth  — keep hidden
		curve.Scalar.New(4), // issuer         — reveal
		curve.Scalar.New(5), // credential type — reveal
	}
	sig, err := sk.Sign(generators, msgs)
	if err != nil {
		log.Fatal(err)
	}

	// ---- holder side ------------------------------------------------------
	// One entry per generator, in signing order.
	proofMsgs := []common.ProofMessage{
		&common.ProofSpecificMessage{Message: msgs[0]},
		&common.ProofSpecificMessage{Message: msgs[1]},
		&common.RevealedMessage{Message: msgs[2]},
		&common.RevealedMessage{Message: msgs[3]},
	}

	pok, err := bbs.NewPokSignature(sig, generators, proofMsgs, crand.Reader)
	if err != nil {
		log.Fatal(err)
	}

	nonce := curve.Scalar.Random(crand.Reader) // supplied by the verifier

	transcript := merlin.NewTranscript(transcriptLabel)
	pok.GetChallengeContribution(transcript)
	transcript.AppendMessage([]byte("nonce"), nonce.Bytes())
	okm := transcript.ExtractBytes([]byte("signature proof of knowledge"), 64)
	challenge, err := curve.Scalar.SetBytesWide(okm)
	if err != nil {
		log.Fatal(err)
	}

	proof, err := pok.GenerateProof(challenge)
	if err != nil {
		log.Fatal(err)
	}

	// ---- verifier side ----------------------------------------------------
	revealed := map[int]curves.Scalar{
		2: msgs[2],
		3: msgs[3],
	}

	vTranscript := merlin.NewTranscript(transcriptLabel) // same label, same order
	ok := proof.Verify(revealed, pk, generators, nonce, challenge, vTranscript)
	fmt.Println("presentation valid:", ok)
}

revealed is keyed by zero-based message index, matching the position in the original msgs slice — not by generator index.

What Verify actually checks, and what VerifySigPok does not

PokSignatureProof.Verify does two independent things:

  1. VerifySigPok(pk) — a pairing check that the randomised signature is a real signature under pk. You can call this on its own.
  2. Challenge equality — it calls GetChallengeContribution(generators, revealedMsgs, challenge, transcript), re-extracts 64 bytes from the transcript, and compares the result to the challenge you passed in. This is what binds the revealed messages to the proof.

Step 2 is why the transcript matters so much. If the verifier reveals a different message set, uses a different transcript label, or appends the nonce at a different point, the recomputed challenge differs and Verify returns false.

You can also drive the two halves manually — the test does exactly this to show BBS+ composing with other sigma protocols that share the transcript:

proof.GetChallengeContribution(generators, revealed, challenge, vTranscript)
// ...other protocols append their contributions to vTranscript here...
vTranscript.AppendMessage([]byte("nonce"), nonce.Bytes())
okm := vTranscript.ExtractBytes([]byte("signature proof of knowledge"), 64)
vChallenge, _ := curve.Scalar.SetBytesWide(okm)

valid := proof.VerifySigPok(pk) && challenge.Cmp(vChallenge) == 0

Blind signing

The dual problem: the issuer must sign an attribute it is not allowed to see — a link secret, a biometric template, a device key. The holder commits to those messages, proves knowledge of the committed values, and the issuer signs the commitment together with the messages it does know.

Holder commits

NewBlindSignatureContext(curve, hiddenMsgs, generators, nonce, reader) returns the context to send to the issuer and a common.SignatureBlinding the holder keeps. hiddenMsgs is a map[int]curves.Scalar keyed by zero-based message index.

Issuer verifies the commitment

ctx.Verify(knownIndices, generators, nonce) checks the holder’s proof of knowledge of the hidden values, so the issuer is not signing arbitrary garbage. knownIndices is the sorted list of indices the issuer supplies.

Issuer signs

ctx.ToBlindSignature(knownMsgs, sk, generators, nonce) produces a *BlindSignature. It calls Verify internally, so a bad commitment fails here too.

Holder unblinds

blindSig.ToUnblinded(blinding) adds the retained blinding factor back into the s component, yielding an ordinary *Signature that verifies against the complete message vector.

Grounded in TestBlindSignatureContext.

package main

import (
	crand "crypto/rand"
	"fmt"
	"log"

	"github.com/sonr-io/crypto/core/curves"
	"github.com/sonr-io/crypto/signatures/bbs"
)

func main() {
	curve := curves.BLS12381(&curves.PointBls12381G2{})
	pk, sk, err := bbs.NewKeys(curve)
	if err != nil {
		log.Fatal(err)
	}
	generators, err := new(bbs.MessageGenerators).Init(pk, 4)
	if err != nil {
		log.Fatal(err)
	}
	nonce := curve.Scalar.Random(crand.Reader)

	// ---- holder: hide message 0 from the issuer ---------------------------
	hidden := map[int]curves.Scalar{
		0: curve.Scalar.Hash([]byte("link-secret")),
	}
	ctx, blinding, err := bbs.NewBlindSignatureContext(curve, hidden, generators, nonce, crand.Reader)
	if err != nil {
		log.Fatal(err)
	}
	// Send ctx (and nonce) to the issuer. Keep `blinding`.

	// ---- issuer: signs only what it knows ---------------------------------
	known := map[int]curves.Scalar{
		1: curve.Scalar.Hash([]byte("firstname")),
		2: curve.Scalar.Hash([]byte("lastname")),
		3: curve.Scalar.Hash([]byte("age")),
	}
	blindSig, err := ctx.ToBlindSignature(known, sk, generators, nonce)
	if err != nil {
		log.Fatal(err)
	}

	// ---- holder: unblind and check ----------------------------------------
	sig := blindSig.ToUnblinded(blinding)

	full := []curves.Scalar{hidden[0], known[1], known[2], known[3]}
	if err := pk.Verify(sig, generators, full); err != nil {
		log.Fatal("unblinded signature invalid: ", err)
	}
	fmt.Println("blind-signed credential valid")
}

The issuer never sees hidden[0]. It only ever handles ctx.commitment, a group element, plus a Schnorr proof that the holder knows the openings.

Serialization

Every type here is a BinaryMarshaler, but the wire format does not carry its curve, so the unmarshalling side needs Init(curve) first:

data, err := sig.MarshalBinary()

restored := new(bbs.Signature).Init(curve)
err = restored.UnmarshalBinary(data)

The same pattern applies to PublicKey, SecretKey, BlindSignature, BlindSignatureContext, and PokSignatureProof. Calling UnmarshalBinary on a zero-valued struct dereferences nil fields and panics.

BlindSignatureContext.MarshalBinary writes the commitment point followed by the challenge and one scalar per proof — PointSize + (N + 1) * ScalarSize bytes.

Caveats

Last updated on September 2, 2026

Was this page helpful?