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
NewKeys(curve *curves.PairingCurve)?(*PublicKey, *SecretKey, error)
Generates a fresh keypair. Note the public key comes FIRST in the return order.
(*PublicKey, *SecretKey, error)NewSecretKey(curve *curves.PairingCurve)?(*SecretKey, error)
Just the signing key.
(*SecretKey, error)SecretKey.PublicKey()?*PublicKey
Derives the verification key. No error return.
*PublicKeyMessageGenerators.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.
(*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.
curves.PairingPointGenerators 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:
VerifySigPok(pk)— a pairing check that the randomised signature is a real signature underpk. You can call this on its own.- 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
Related
Proof toolkit
signatures/common — ProofMessage, ProofCommittedBuilder, HmacDrbg, and the scalar aliases
this page uses.
Schnorr proofs
The standalone sigma protocol, for composing proofs of discrete-log knowledge alongside a BBS+ presentation.
Accumulator
Constant-size set membership on the same pairing curve — the usual companion for revocation.
Security notes
Every defect found while documenting this library, in one place.