Selective disclosure with BBS
BBS is a signature scheme built for credentials. An issuer signs a list of attributes once. The holder can then show any subset of those attributes to a verifier, together with a zero-knowledge proof that the issuer signed the full set. The hidden attributes stay hidden, and two presentations of the same credential can’t be linked to each other by the proof alone.
Think of a passport where you can black out any lines you like before showing it, and the border officer can still check the government’s stamp.
ZeroJ implements BBS in the opt-in zeroj-bbs module, following the IRTF CFRG draft
draft-irtf-cfrg-bbs-signatures-10. It is a draft, not yet an RFC.
BBS or a circuit?
Section titled “BBS or a circuit?”| You want to… | Use |
|---|---|
| Reveal some signed attributes exactly as issued (“country = NZ”) and hide the rest | BBS: no circuit, no trusted setup |
| Prove a predicate over a hidden value (“age ≥ 18”, “balance > 1000”) | A Groth16 circuit (see Prove you’re over 18) |
| Prove membership in a set without revealing which member | A circuit with a Merkle proof (private allowlist) |
| Verify on Cardano with a flexible statement | Groth16. On-chain BBS supports one fixed disclosure shape (see below). |
BBS reveals values; it doesn’t compute on them. If the verifier only needs a yes/no answer about a hidden attribute, you want a circuit.
Status
Section titled “Status”| Operation | Status |
|---|---|
Verification (Verify, ProofVerify) | Beta: vector-tested, not audited, spec is an IRTF draft |
Issuance and presentation (KeyGen, Sign, ProofGen) | Beta with caveat: the default pure-Java provider is not constant-time; prefer the blst provider for issuer keys |
Both BLS12-381 ciphersuites from the draft are implemented: BLS12381_SHA256 (the default,
BBS_BLS12381G1_XMD:SHA-256_SSWU_RO_) and BLS12381_SHAKE256. The test suite runs the draft’s
official fixture vectors for both, across the pure-Java and blst providers.
Add the dependency
Section titled “Add the dependency”zeroj-bbs is published but sits outside zeroj-bom-core, so give it an explicit version:
dependencies { implementation platform('org.zeroj:zeroj-bom-core:0.1.0-pre12') implementation 'org.zeroj:zeroj-bbs:0.1.0-pre12' // opt-in, explicit version implementation 'org.zeroj:zeroj-blst' // optional: native provider for issuers}<dependency> <groupId>org.zeroj</groupId> <artifactId>zeroj-bbs</artifactId> <version>0.1.0-pre12</version></dependency>Issue, present, verify
Section titled “Issue, present, verify”This complete program plays all three roles. Each role would normally be a different party.
import org.zeroj.bbs.BbsCiphersuite;import org.zeroj.bbs.BbsKeyPair;import org.zeroj.bbs.BbsPresentation;import org.zeroj.bbs.BbsPresentationCodec;import org.zeroj.bbs.BbsPublicKey;import org.zeroj.bbs.BbsRevealedMessage;import org.zeroj.bbs.BbsService;import org.zeroj.bbs.BbsSignature;
import java.nio.charset.StandardCharsets;import java.security.SecureRandom;import java.util.Arrays;import java.util.List;
public class BbsQuickstart {
public static void main(String[] args) { BbsService bbs = BbsService.pureJava(); // draft-10, BLS12-381 SHA-256 ciphersuite
// ---- Issuer: create a key pair (key material is secret, at least 32 bytes) ---- byte[] keyMaterial = new byte[32]; new SecureRandom().nextBytes(keyMaterial); BbsKeyPair issuer = bbs.keyPair(keyMaterial, utf8("acme-kyc-issuer-2026"));
// ---- Issuer: sign five attributes under a credential header ---- List<byte[]> attributes = List.of( utf8("name:Alice Liddell"), // 0 utf8("dob:1990-04-01"), // 1 utf8("country:NZ"), // 2 utf8("kycLevel:verified"), // 3 utf8("docId:P1234567")); // 4 byte[] header = utf8("acme-kyc-credential-v1"); BbsSignature signature = bbs.sign(issuer.secretKey(), issuer.publicKey(), attributes, header);
// ---- Holder: check the credential on receipt ---- boolean credentialOk = bbs.verify(issuer.publicKey(), signature, attributes, header);
// ---- Verifier: issue a fresh, single-use challenge ---- byte[] challenge = new byte[32]; new SecureRandom().nextBytes(challenge);
// ---- Holder: reveal only country (2) and kycLevel (3), bound to the challenge ---- BbsPresentation presentation = bbs.derivePresentation( issuer.publicKey(), signature, attributes, header, challenge, new int[]{2, 3}); byte[] wire = BbsPresentationCodec.encode(presentation); // send this to the verifier
// ---- Verifier: decode, check the bindings, then the proof ---- BbsPresentation received = BbsPresentationCodec.decode(wire); BbsPublicKey trustedIssuer = new BbsPublicKey(issuer.publicKey().bytes(), BbsCiphersuite.BLS12381_SHA256); boolean freshAndBound = Arrays.equals(received.presentationHeader(), challenge) && Arrays.equals(received.header(), header); boolean proofOk = bbs.verifyPresentation(trustedIssuer, received);
System.out.println("credentialOk=" + credentialOk + " freshAndBound=" + freshAndBound + " proofOk=" + proofOk); for (BbsRevealedMessage m : received.revealedMessages()) { System.out.println(" revealed[" + m.index() + "] = " + new String(m.message(), StandardCharsets.UTF_8)); } }
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }}Output:
credentialOk=true freshAndBound=true proofOk=true revealed[2] = country:NZ revealed[3] = kycLevel:verifiedA few API details worth knowing:
- Argument order.
sign(secretKey, publicKey, messages, header)andverify(publicKey, signature, messages, header)put messages before the header. Overloads with the draft’s order,(…, header, messages), also exist. - Disclosed indexes are zero-based and must be strictly ascending:
new int[]{2, 3}, not{3, 2}. - Every presentation is fresh.
derivePresentationdraws new randomness fromSecureRandomeach time, so repeated presentations don’t share proof bytes. - Tampering fails. Change a revealed message, the header or
ph, andverifyPresentationreturnsfalse. - Distribute the issuer key as bytes.
publicKey.bytes()on the issuer side,new BbsPublicKey(bytes, BbsCiphersuite.BLS12381_SHA256)on the verifier side. Verifiers must get it from a source they trust, not from the holder.
Headers and presentation headers
Section titled “Headers and presentation headers”BBS has two “extra data” fields, and they do different jobs:
| Set by | Signed/proved over | Use it for | |
|---|---|---|---|
| header | Issuer, at signing | The signature and every presentation | Credential type, schema version, issuer context. Verifiers should expect a specific value. |
presentation header (ph) | Holder, at presentation, usually from the verifier’s challenge | That one presentation | Binding a presentation to one session or transaction, which prevents replay |
verifyPresentation(publicKey, presentation) takes both values from the presentation itself.
It proves the holder used some ph; it does not know which one you expected. Always compare
presentation.presentationHeader() with the challenge you issued and consume the challenge, and
compare presentation.header() with the credential type you accept. Without that, a captured
presentation can be replayed forever.
On Cardano there is no interactive challenge, so derive ph from the transaction instead. For
example, blake2b_256(spentTxId || outputIndex || recipientPkh), recomputed by the validator from
ScriptContext. Then a presentation only works for the one UTxO and recipient it was made for.
Encode attributes carefully
Section titled “Encode attributes carefully”Revealed messages are raw bytes. The proof says “the issuer signed these bytes at these positions”, nothing more.
- Fix the schema. Agree on what each index means (0 = name, 2 = country, …) and have verifiers check the index of every revealed message, not just its value.
- Make values unambiguous. Prefix values with the attribute name, or use a canonical encoding,
so
"NZ"in one position can’t be confused with another field. - Remember what BBS doesn’t do. Expiry, revocation, holder binding (proving the presenter is the person the credential was issued to), and issuer trust are application policy.
Choose a provider
Section titled “Choose a provider”BbsService.pureJava() needs no native code. Its secret-scalar operations go through
fixed-schedule code, but it is not a full JVM constant-time guarantee. For issuer keys, and
anywhere high-value signing keys or proof randomness live, select the native blst provider. The
API stays the same:
import org.zeroj.bbs.BbsCiphersuite;import org.zeroj.bbs.BbsService;import org.zeroj.blst.BlstBls12381Provider;
BbsService issuerService = BbsService.withBlsProvider( BbsCiphersuite.BLS12381_SHA256, BlstBls12381Provider.createDefault());Signatures and presentations produced with either provider verify with the other: the key pair derived from the same key material is identical.
Verify through the ZeroJ SPI
Section titled “Verify through the ZeroJ SPI”If your service already routes proofs through ZeroJ’s verifier registry, BbsZkVerifier
(registered with ServiceLoader, descriptor name bbs-bls12381-java) verifies a presentation carried in a ZkProofEnvelope:
proofSystem=ProofSystemId.BBS,curve=CurveId.BLS12_381;- proof bytes =
BbsPresentationCodec.encode(presentation), proof formatbbs-cfrg-draft10-presentation-cbor-v1; VerificationMaterialVK bytes = the issuer’s public key bytes.
Like every ZeroJ backend, it checks cryptography only. The ph/header comparison above is still
yours. See Verify proofs in Java.
Verify on Cardano
Section titled “Verify on Cardano”zeroj-onchain-julc includes BbsProofVerify, a native Plutus V3 implementation of BBS
ProofVerify, and BbsToCardano in zeroj-bbs prepares its inputs off-chain:
import org.zeroj.bbs.cardano.BbsToCardano;
var params = BbsToCardano.verifierParams(issuer.publicKey(), header, attributes.size()); // validator @Paramsvar proof = BbsToCardano.onChainProof(presentation); // redeemer fieldsverifierParams bakes the issuer key, the generators and the header-dependent domain into the
validator’s parameters, so the script hash pins both the issuer and the credential header.
Your validator composes BbsProofVerify with its own policy: check that the disclosed values match
what the datum requires, that ph equals the value recomputed from ScriptContext, and that the
payout goes to the intended recipient. The reusable-kyc app in
zeroj-usecases is a complete worked example. See
Verify proofs on Cardano for the general validator pattern.