Skip to content

Selective disclosure with BBS

View Markdown

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.

You want to…Use
Reveal some signed attributes exactly as issued (“country = NZ”) and hide the restBBS: 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 memberA circuit with a Merkle proof (private allowlist)
Verify on Cardano with a flexible statementGroth16. 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.

OperationStatus
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.

zeroj-bbs is published but sits outside zeroj-bom-core, so give it an explicit version:

build.gradle
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
}
pom.xml
<dependency>
<groupId>org.zeroj</groupId>
<artifactId>zeroj-bbs</artifactId>
<version>0.1.0-pre12</version>
</dependency>

This complete program plays all three roles. Each role would normally be a different party.

BbsQuickstart.java
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:verified

A few API details worth knowing:

  • Argument order. sign(secretKey, publicKey, messages, header) and verify(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. derivePresentation draws new randomness from SecureRandom each time, so repeated presentations don’t share proof bytes.
  • Tampering fails. Change a revealed message, the header or ph, and verifyPresentation returns false.
  • 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.

BBS has two “extra data” fields, and they do different jobs:

Set bySigned/proved overUse it for
headerIssuer, at signingThe signature and every presentationCredential type, schema version, issuer context. Verifiers should expect a specific value.
presentation header (ph)Holder, at presentation, usually from the verifier’s challengeThat one presentationBinding 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.

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.

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.

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 format bbs-cfrg-draft10-presentation-cbor-v1;
  • VerificationMaterial VK 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.

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 @Params
var proof = BbsToCardano.onChainProof(presentation); // redeemer fields

verifierParams 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.