Selective Disclosure
Open only the receipt or workflow fields a recipient needs, and let its verifier check them against one BBS signature.
On this page
A holder sends another organization a receipt with most of its fields closed. It opens the values the recipient needs, and the recipient checks that each one belongs to a vector signed by a BBS issuer it accepts, at the slot and meaning the projection assigns.
The running example is the three-vendor refund workflow, in which you, the buyer, open four of fourteen workflow fields for an auditor. Signing, derivation, and verification ship in chio-selective-disclosure behind its bbs Cargo feature, which is off by default.
The buyer’s proof opens four of fourteen workflow messages
One BBS signature covers the whole workflow projection. The 3-Vendor Walkthrough’s proof opens slots 4, 8, 9, and 10.
The proof opens four of fourteen messages
One BBS signature covers the fourteen messages of the workflow projection. The three-vendor proof opens slots 4, 8, 9, and 10: id wf-chio-refund-001, session_id sess-chio-refund, skill_id refund-underwriting, and skill_version 0.1.0. The other ten values stay unopened inside the proof. Selective Disclosure.
Decoded from the package, those are id wf-chio-refund-001, session_id sess-chio-refund, skill_id refund-underwriting, and skill_version 0.1.0. The other ten values stay unopened, yet the proof still carries the subject digest, projection version, ciphersuite, issuer identity and public key, message count, disclosed indices, nonce, and proof bytes. Those public values can identify or correlate the subject.
Check the whole delivery. The buyer audit package carries the full workflow receipt and the tool receipts beside the proof, so its recipient reads steps and costs there even though their BBS messages stay unopened. The buyer verifier uses those full records to check the proof’s subject and disclosed bytes.
Proof structure and the captured fixture
/// Buyer or auditor proof package carrying only disclosed fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectiveDisclosureProof {
pub schema: String,
pub projection_version: String,
pub subject_sha256_hex: String,
pub ciphersuite: String,
pub issuer_fingerprint: String,
pub issuer_public_key_hex: String,
pub message_count: usize,
pub disclosed_indices: Vec<u16>,
pub disclosed: Vec<DisclosedMessage>,
pub proof_nonce_hex: String,
pub proof_bytes_hex: String,
}{
"schema": "chio.attest.selective-disclosure-proof.v1",
"projection_version": "chio.bbs-projection.workflow.v1",
"subject_sha256_hex": "0a29c62e2ec376a12d147bd3f9ffdd332a19792ebd15d035ae5684bc894e4710",
"ciphersuite": "BBS_BLS12381G1_XMD:SHA-256_SSWU_RO_",
"issuer_fingerprint": "70113a55c73186df1b5812a3c421f1ee8061acfda7fb4009f5a5432102ddaca3",
"issuer_public_key_hex": "ac96b29b82fd2aafa60ec4a4cf720073e1e8fe232f24cb0125b3e89397176e66c170794a6cec5b6440b00fdf88d5851b0d08d0a729499b56940e76cc1ae33e838602a52a3667f24ca9c0aeb1baeaccd972cc46eeb2d1f3755c6ed950c998c7d5",
"message_count": 14,
"disclosed_indices": [
4,
8,
9,
10
],
"disclosed": [
{
"index": 4,
"field": "id",
"encoding": "S",
"bytes_hex": "77662d6368696f2d726566756e642d303031"
},
{
"index": 8,
"field": "session_id",
"encoding": "Opt<S>",
"bytes_hex": "736573732d6368696f2d726566756e64"
},
{
"index": 9,
"field": "skill_id",
"encoding": "S",
"bytes_hex": "726566756e642d756e64657277726974696e67"
},
{
"index": 10,
"field": "skill_version",
"encoding": "S",
"bytes_hex": "302e312e30"
}
],
"proof_nonce_hex": "34373538613933663264626331363235363130356365366434376134656664643339643437373733336463623261356538303037356133653161616464636365",
"proof_bytes_hex": "816101387daa8084da8e7c7f08bf66252271a3f771419726d913b499ca40edea1c246d86fd676ac57697269fc9c970deadd8a9d51786475a7d82a1e8f3b4d146b24f4d17f8039fceab57aef8a4c2f18d8f81e267edc2b14adff08264aaa71a2a8e990cb3a4098e8701bf49603ba6018aaf629e55d0022446b0186d89519772c4aeaa52662c09f28e810fddd4fc09f27822624f5f95b2f775400c62fbdacb469aff1c06de7e5658cd9e453c3bbee96622643fe3357bf0fca3eaa88188885e2438565f1022b61bfa299ca0a96bddfa8c214497e492108a85f7377aab2bf03e4e7b95cbdc45526adcfa06cee2b5efbe5e174ffb690bbeade070fbf91419d4be5a8699079f26963253255adf990f5b3033ad3e58f5d34cb70f60746a554f81c5e40293c9c46c7c868f76bcf7ae824d9c1f7a291ff2ac6542e9304113eeda01f34c97bdf45055b55e1e599a31f80ac959ef2536f29ae76b32fdb81c066a33e009efdef7a56d67c8c985f5ae5092980f40f3a369d12e2f816b138cce34ca8f2c1e5c6f2a9cb3376e9998720967e51606e41051289556a7a251c915b0ad79b9f52713e97a5435f723b798964553bead8888bac602e1c3eed480589b3738c003e015e9822d935ab30e529996ab3b7f002252ae3267aac46f1e78d560b6e772fe4e1a60f866da43e6353750195eadcc6db1d173035d7d39f00ba7ba095663e106ebb82aaf5e7d1ef14f784f76895ac3697a82b9f811f5e9c9e31473cf73e61b7c058bb6f0df6d42d1334a53bdf93c8099fe5f63030a91196a429f99b1b5bed6d8e965b01b8aaf1ccfec40755d935e5a93243b9c76"
}A BBS signature covers an ordered projection of the signed receipt
A receipt and its BBS signature serve different verification paths, under separate signing keys. The receipt signature authenticates the canonical body; the BBS signature authenticates an ordered projection of that body, so a later proof can open selected messages.
The sign_chio_receipt_with_bbs helper prepares the receipt body, checks its content hash against the one-time signing handle, signs the final projection with BBS, and embeds that material in an Ed25519-signed receipt. The ciphersuite is BBS_BLS12381G1_XMD:SHA-256_SSWU_RO_. Its header binds the projection version, canonical subject digest, issuer fingerprint, and ciphersuite, and each message binds its index, field name, encoding, and encoded bytes under a separate domain, so a revealed value has one slot and one meaning.
The subject digest commits to the full canonical subject, which may cover fields beyond the projected slots; only projected slots can be opened as BBS messages. The receipt format defines the broader algorithm contract.
Three projection tables give every slot a fixed meaning
Indices are local to a table: workflow slot 4 is id, while receipt slot 4 is evidence. Always carry the projection version with a reveal set.
| Subject | Projection version | Messages |
|---|---|---|
| Receipt body | chio.bbs-projection.receipt.v1 | 14 |
| Workflow body | chio.bbs-projection.workflow.v1 | 14 |
| Step with workflow identifier | chio.bbs-projection.step.v1 | 10 |
The step projection’s subject is the hash of the canonical pair of workflow identifier and StepRecord, and its vector includes the workflow identifier as one message. The BBS library supports all three versions; the buyer audit package’s disclosure policy accepts the receipt and workflow versions.
The tables happen to be alphabetical, but the ordered projection calls define membership and indices, so never rebuild them by sorting a newer receipt struct. Adding, removing, renaming, or re-encoding a projected message takes a new versioned contract and matching verifier support.
Read more in Receipt slots, Workflow slots, and Message bytes.
Each verifier checks one layer of acceptance
A cryptographically valid reveal is one input to acceptance. The caller chooses trusted issuers and establishes the presentation context, and four verifiers check different layers:
- Generic proof verifier. Schema, supported version and suite, registry key match, message count, reveal indices, and the BBS proof over the header and supplied nonce. The caller populates the issuer registry from trust policy and enforces required fields, the expected subject, and the projection contract.
- Context-aware verifier. The proof, then key, revocation, audience, nonce, holder, transparency, and age checks against the supplied privacy profile. The caller supplies trustworthy context observations and reads the returned verdict.
- Buyer audit-package verifier. Full package bindings, projection and disclosed-byte agreement, required indices and fields, a context-derived nonce, a trusted BBS issuer, and the proof. The caller supplies the trust bundle and verification context, and accounts for the full receipts in the package.
- Projection-manifest verifier. Manifest shape, subject and version agreement, slot metadata, and the exact declared disclosure selection. BBS cryptography and trust are checked separately.
The generic verifier authenticates the signed vector it receives without rebuilding the receipt body or applying the helper’s fixed slot table. The context-aware wrapper can return an Ok report whose verdict is Rejected: cryptography passed and a context check failed. Acceptance requires the Verified verdict, and the report stays unsigned until a separate signing step.
Read more in Nonce and context checks, which binds a proof to one presentation.
A proof shown to a changed challenge fails verification
Project a receipt, sign the projection with a fresh BBS key, select slots 1, 5 and 11, and derive a proof for the auditor's challenge. The verifier pins the issuer and checks its own outstanding challenge. Build it from the downloaded project with the Rust example setup.
cargo run --locked --features disclosure --bin disclosureverified: [{"index":1,"field":"capability_id","encoding":"S","bytes_hex":"6361702d6368696f2d72656365697074"},{"index":5,"field":"id","encoding":"S","bytes_hex":"726370742d6368696f2d626273"},{"index":11,"field":"tool_name","encoding":"S","bytes_hex":"726561645f726566756e645f63617365"}]
changed challenge: BBS proof verification failed
different outstanding challenge accepted: falseYou should see three verified messages, capability_id, id, and tool_name, with their encoded bytes in hex. Changing the challenge fails BBS verification, and a different outstanding challenge is not accepted. The relying party enforces audience, holder, revocation, and replay policy through the context layer.
A manifest declares exactly which slots a proof opens
A chio.bbs-projection.manifest.v2 gives each slot a field, encoding, message class, sensitivity class, disclosure choice, and wholesale flag. verify_bbs_projection_manifest checks it against a proof, apart from the BBS cryptography, and refuses the pairing when:
- the proof opens a Hidden or wholesale-only slot;
- a Disclosed slot is absent from the proof, since Disclosed declares the selected reveal;
- a revealed field name or encoding differs from its slot;
duration_msis marked Disclosed, which refuses that exact-timing disclosure before any message is compared;- the subject, version, count, slot range, or uniqueness disagrees.
The helper-generated manifest hides wholesale-only messages and duration_ms and marks the rest Disclosed: a starting policy. For the four-field workflow reveal, mark the other slots Hidden before checking the manifest against the proof. The generic BBS verifier leaves manifest policy to this check, because verifying a revealed message and permitting its reveal are separate decisions.
Read more in Disclosure Lineage, which adds provenance and disclosure-policy accounting.
A reveal set opens values; a hidden comparison needs its own proof
The buyer audit package accepts bbsRevealSet: true and requires hiddenRangePredicates, vcDataIntegrityBbs, and zkvm to be false. It refuses any other claim before the cryptographic checks.
Revealing the signed skill identifier while the steps stay unopened is a reveal set. A statement about an unopened value is a comparison, and it takes a proof of that comparison bound to the signed subject: detection confidence reaching a threshold without revealing the indicator, a settlement amount under a cap without revealing the amount, a KYC tier at a required floor without revealing the tier. KYC tier is also absent from all three projection tables.
The manifest’s hidden-predicate entries declare identifiers, fields, operators, and optional digests. Their shape checks prove no comparison over a hidden value.
Slots, encodings, and proof size
Try policy_hash, Hx, or context nonce.
Receipt slots
The wholesale_only flag guides the manifest helper. It leaves an H message a digest.
| Field | Slot | Encoding | Wholesale only |
|---|---|---|---|
action | 0 | H | true |
capability_id | 1 | S | false |
content_hash | 2 | Hx | false |
decision | 3 | H | true |
evidence | 4 | H | true |
id | 5 | S | false |
kernel_key | 6 | H | false |
metadata | 7 | H | true |
policy_hash | 8 | Hx | false |
tenant_id | 9 | Opt<S> | false |
timestamp | 10 | U64 | false |
tool_name | 11 | S | false |
tool_server | 12 | S | false |
trust_level | 13 | S | false |
Workflow slots
| Field | Slot | Encoding | Wholesale only |
|---|---|---|---|
agent_id | 0 | S | false |
capability_id | 1 | S | false |
completed_at | 2 | U64 | false |
duration_ms | 3 | U64 | false |
id | 4 | S | false |
kernel_key | 5 | H | false |
outcome | 6 | H | true |
schema | 7 | S | false |
session_id | 8 | Opt<S> | false |
skill_id | 9 | S | false |
skill_version | 10 | S | false |
started_at | 11 | U64 | false |
steps | 12 | H | true |
total_cost | 13 | H | true |
Step slots
| Field | Slot | Encoding | Wholesale only |
|---|---|---|---|
allowed | 0 | Bool | false |
cost | 1 | H | true |
duration_ms | 2 | U64 | false |
outcome | 3 | H | false |
output_hash | 4 | Opt<S> | false |
server_id | 5 | S | false |
step_index | 6 | U64 | false |
tool_name | 7 | S | false |
tool_receipt_id | 8 | Opt<S> | false |
workflow_id | 9 | S | false |
Message bytes
| Encoding | Recipe | Disclosure exposes |
|---|---|---|
S | Raw UTF-8 bytes. | The string value. |
Opt<S> | UTF-8 for a present string; one 0x00 byte for None. | The encoded optional value. |
U64 | Eight bytes, little-endian. | The integer value. |
Bool | One byte: 0 for false, 1 for true. | The boolean value. |
H | SHA-256 of canonical JSON for the field. | A 32-byte digest of the structured value. |
Hx | Decode an existing 64-character lowercase hexadecimal digest. | The existing 32-byte digest. |
The proof serializes encoded bytes as hexadecimal in bytes_hex. Hx inputs with an algorithm prefix, uppercase letters, or the wrong length fail projection. A receipt’s kernel_key uses H, so it hashes the key’s canonical representation instead of going through the Hx decoder.
pub(crate) fn decode_hx_field(field: &str, raw: &str) -> Result<Vec<u8>, SelectiveDisclosureError> {
if raw.is_empty() {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: "empty string is not a SHA-256 hex digest".to_string(),
});
}
if !is_lower_sha256_hex(raw) {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: "expected 64 lowercase SHA-256 hex characters".to_string(),
});
}
let bytes = hex::decode(raw).map_err(|e| SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: e.to_string(),
})?;
if bytes.len() != 32 {
return Err(SelectiveDisclosureError::MalformedHexField {
field: field.to_string(),
reason: format!("decoded length {} bytes does not equal 32", bytes.len()),
});
}
Ok(bytes)
}Projection and message structures
/// A typed BBS projection over a Chio receipt-like object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Projection {
pub version: String,
pub subject_sha256_hex: String,
pub messages: Vec<ProjectionMessage>,
}/// One projected message in a Chio BBS vector.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionMessage {
pub index: u16,
pub field: String,
pub encoding: String,
pub bytes_hex: String,
pub wholesale_only: bool,
}Nonce and context checks
verify_selective_disclosure_proof verifies the nonce the proof carries and takes no expected-nonce argument, so a valid proof with an old nonce passes this layer unless the caller checks the expected context.
verify_selective_disclosure_with_context compares the context nonce to the proof and checks the supplied replay status when the profile selects no_replay. Key state, revocation freshness, holder-binding status, and transparency state are supplied observations too, established by the embedding service. Labeling a nonce Fresh consumes nothing atomically; the service owns its replay store and fetches key state itself.
Hidden-predicate declaration and package claims
/// One hidden predicate declared by a BBS projection manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BbsProjectionHiddenPredicate {
pub predicate_id: String,
pub field: String,
pub operator: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_sha256: Option<String>,
}pub struct ChioProofClaims {
pub bbs_reveal_set: bool,
pub hidden_range_predicates: bool,
pub vc_data_integrity_bbs: bool,
pub zkvm: bool,
}Proof size
For the pinned BBS ciphersuite, the binary proof is 272 fixed bytes plus 32 bytes per undisclosed message. The verifier derives a message count from that length and the disclosed count and compares it to the declared count before verifying the proof.
The fixture has fourteen messages, four disclosed and ten undisclosed: 272 + 32 × 10 = 592 bytes, and its hexadecimal proof string is twice that many characters. The figure excludes the surrounding JSON, public metadata, and disclosed values. The verifier rejects a binary proof shorter than 272 bytes or a remainder that is not a whole number of 32-byte scalars; a matching length is a structural check ahead of the cryptography.
const BBS_SHA256_POINT_BYTES: usize = 48;
#[cfg(feature = "bbs")]
const BBS_SHA256_SCALAR_BYTES: usize = 32;
#[cfg(feature = "bbs")]
const BBS_SHA256_PROOF_FIXED_BYTES: usize =
(3 * BBS_SHA256_POINT_BYTES) + (4 * BBS_SHA256_SCALAR_BYTES);Source
The projection and BBS implementation, the context policy, and the buyer disclosure contract define these checks. Example values and size arithmetic come from the committed three-vendor fixture.