Selective disclosure
Selective disclosure lets a holder show a verifier some fields of a credential (for example the programme) without showing the rest (the name, the student id). The holder hands over a small disclosure file instead of the whole credential package. The verifier checks it in the browser against the chain, with no wallet.
The feature has three parts:
- Attestation. The issuer signs the root of a salted tree built from the credential's display fields. The signature and the salts go into the credential package.
- Disclosure. The holder picks fields, takes a fresh challenge from the verifier, and signs a request with their wallet. The app writes a disclosure file.
- Verification. The verifier's page recomputes the tree path of every shown field, checks the issuer's signature over the root, checks that the holder's signature comes from the wallet the chain binds to this student, checks the challenge, and reads the credential's live status.
No contract is involved beyond the existing registries. There is no circuit: shown values are revealed to the verifier, and fields that are not shown stay unknown. A hidden-value predicate ("graduated after 2020" without the date) was considered and deferred; see "Limits" below.
Who takes part
| Actor | Role in the flow | Wallet |
|---|---|---|
| Issuer | Signs the attribute tree once per credential (at issue time, in bulk import, or later from the dashboard). | Yes, the issuing key |
| Holder | Picks fields and signs one disclosure per verifier challenge. | Yes, the wallet bound to the student through an enrolment invitation |
| Verifier | Shows a challenge, receives the file, reads the verdict. | No |
Preconditions
- The app runs against the v0.3 contracts (
NEXT_PUBLIC_CONTRACT_VERSION=v03). The v0.2 deployment has no disclosure support. - The credential package carries the holder details (
display) and the attribute data (attributes). Packages made before the feature existed have noattributes; the issuer can add them later (see Adding disclosure to an earlier credential). - For a result of Disclosure verified, the student's wallet must be bound on chain through the
HolderRegistry(see Enrolment). Without a binding the verdict says the disclosure cannot be fully verified. - The credential must be valid now (active, not expired, issuer not suspended) and its issuer, or the key now in charge of it, must be registered.
The attribute tree
The tree is built in src/lib/disclosure/attributes.ts. It always has 8 leaves (ATTRIBUTE_SLOTS) and depth 3, whatever the credential holds, so a missing field looks the same as a hidden one.
| Slot | Field | Source |
|---|---|---|
| 0 | holderName | Display field, required |
| 1 | studentId | Display field, required |
| 2 | programme | Display field, optional |
| 3 | award | Display field, optional |
| 4 | awardedOn | Display field, optional (YYYY-MM-DD) |
| 5 to 7 | reserved | Always empty leaves in version 1 |
Each slot has its own random 32-byte salt (generateAttributeSalts). Leaves are domain-separated keccak256 hashes of ABI-encoded values:
// a field that is present
keccak256(abi.encode("certichain.attr.v1", uint8 index, string name, string valueNFC, bytes32 salt))
// a slot with no value
keccak256(abi.encode("certichain.attr.empty.v1", uint8 index, bytes32 salt))
Parents are keccak256(left || right). Values are normalised to Unicode NFC before hashing, so the same visible text always gives the same leaf. A path from a leaf to the root is three sibling hashes.
To show slot 2 (programme), the holder reveals the value, its salt and three siblings: the slot 3 leaf, node A and node N11. The verifier recomputes the root from those and compares it with the signed root.
Step 1: the issuer attests the tree
The issuer signs an EIP-712 message of type AttributeSet under the same domain as credentials (name Certichain, version 1, the chain id and the CredentialRegistry address of the credential's set):
AttributeSet: [
{ name: "credentialId", type: "bytes32" },
{ name: "attributesRoot", type: "bytes32" },
{ name: "version", type: "uint32" },
]
DisclosureService.attest (in src/services/domain/disclosure-service.ts) refuses to sign unless:
- the credential exists on this network (
Credential not found on this network.), - the connected wallet is the credential's issuer (
Only the issuer of a credential can add selective disclosure to it.), - the issuer key in charge is registered (
This issuer is not registered, so nothing was signed.), StatusRegistry.isValidis true (This credential is no longer active, so nothing was signed.).
The result is stored in the package as attributes: { version: 1, salts: [8 salts], root, attestation }.
There are three ways the attestation is made:
| When | Where | Prompt |
|---|---|---|
| Right after a single issue | /records/add, automatically after the issue transaction (submit-record.ts, withAttestation) | One extra signature. If it fails or is rejected, the credential still stands and the card shows: "The credential is issued, but the selective disclosure signature was not added. Add it later from the issuer dashboard." |
| During a bulk import | /records/import, opt-in option Add a selective disclosure signature to each credential ("One more wallet prompt for every credential.") | One extra signature per row |
| Later | /dashboard, panel Add selective disclosure to an earlier credential | One signature per package |
Adding disclosure to an earlier credential
Credentials issued before the feature, or whose attestation prompt was rejected, have packages without attributes. The issuer opens /dashboard and chooses the package in Credential package file. DisclosureService.attestPackage:
- parses the package (
This file is not a credential package.otherwise), - checks its details against the chain with
CredentialService.checkPackage(the salted subject and metadata commitments must match the anchor); if they do not, it stops withThe details in this package are not proven by the chain, so nothing was signed., - signs a fresh tree (new salts) for those proven details,
- downloads the updated package as
credential-<first 8 hex of id>-disclosure.jsonwith the notice Updated package saved. Give it to the holder.
The issuer does not open the encrypted document for this: the display fields are not inside the container, so the package is the only source (decision ADR-0075 in the handbook).
Step 2: the holder makes a disclosure file
The holder works on /student, in the group Show only what is needed, panel Share some details only.
- The verifier gives the holder a challenge: a random 32-byte value shown on the verifier's page (see step 3). It travels by any channel (chat, email, read aloud as hex).
- The holder chooses the package in Package to choose details from. The page lists only the fields this credential has (
disclosableFields). A package without details or attribute data stops withThis credential package has no selective disclosure data. Ask the issuer to add it. - The holder ticks fields under Fields to show, pastes the challenge into Verifier's challenge and clicks Create disclosure file.
DisclosureService.createDisclosurerecomputes the root from the package's details and salts and refuses a mismatch (The details and the attribute data in this package do not match.). It builds the shown fields with their paths, then asks the wallet to sign aDisclosureRequest:
DisclosureRequest: [
{ name: "credentialId", type: "bytes32" },
{ name: "attributesRoot", type: "bytes32" },
{ name: "fieldMask", type: "uint8" }, // bit i set when slot i is shown
{ name: "challenge", type: "bytes32" },
]
- The browser downloads
disclosure-<first 8 hex of id>.jsonand shows Disclosure file saved. Send it to the verifier who gave you the challenge.
The file holds the shown fields (value, salt, three siblings), the root, the issuer's attestation, the challenge, the holder's address and signature, and the signing domain. The full field list is in Disclosure file. The hint under the challenge field tells the holder what else the verifier learns: their wallet address, the issuer and the credential id.
Step 3: the verifier checks it
The verifier opens the credential on /verify?id=... and scrolls to Check what the holder gave you, panel Check a disclosure.
- The panel generates a challenge with
DisclosureService.newChallenge(32 random bytes fromethers.randomBytes) and shows it with Copy and New challenge buttons. The challenge lives only in the page's memory. - The verifier gives it to the holder and later chooses the returned file in Disclosure file. Files over 64 KiB are refused before reading.
- The page checks the file, entirely in the browser plus chain reads:
The checks, in the order DisclosureService.verify runs them:
| Check | Fails how |
|---|---|
File is a certichain-disclosure version 1 file with exactly the known keys, 1 to 8 fields, values of at most 500 characters, signatures of at most 2048 bytes, file of at most 32,768 characters | Error shown, "This is not a verification result." |
| Each shown field: listed once, in slot order, name matches its slot, leaf plus siblings reaches the root | The field <name> does not belong to the signed attributes. (error) |
Signing domain name and version are Certichain and 1, and attribute version is 1 | Error |
Domain chain id and verifyingContract are this network's registry | Verdict: made for a different network |
| The credential exists | Verdict: not found |
StatusRegistry.isValid is true | Verdict line: no longer active (revoked, suspended, expired or issuer suspended) |
| The key now in charge of the issuer is registered | Verdict line: the issuer is not registered |
| The attestation recovers to the credential's issuer, or the issuer is a contract wallet that accepts it through ERC-1271 | Verdict line: the issuer did not sign this set of details |
HolderRegistry.holderOf(issuer, subjectCommitment) is a wallet, and the holder signature recovers to it (or that contract wallet accepts it) | Verdict line: not signed by the bound wallet, or no wallet is bound |
| The file's challenge equals the challenge on screen | Verdict line: the challenge does not match |
Disclosure verified appears only when every row passes (isFullyVerified). The page then says the values are "as signed by the issuer for this credential, shown by the holder who owns it". Values are shown only when the credential was found and the attestation is valid; otherwise the verifier sees the reasons without the values. Every result carries two reminders: fields that are not shown are unknown, and two disclosures of the same credential can be linked by their shared id and root.
Replay protection
A disclosure file is bound to one challenge by the holder's signature. A verifier who clicks New challenge gets a new value, so the same file then fails with "The challenge does not match the one shown above. This file may have been made for someone else." A file captured by a third party cannot be reused against a different verifier's challenge. The challenge is held in page state only: it is not stored on a server and is not tied to the verifier's identity, so a verifier who keeps the same page open accepts the same file again.
What is stored where
| Item | Where |
|---|---|
| Salts, root, attestation | In the credential package (attributes), held by the holder |
| Shown values, their salts and paths, holder signature, challenge | In the disclosure file, given to one verifier |
| Credential status, issuer registration, holder binding | On chain, read at check time |
| Challenge | Verifier page memory |
Nothing is written on chain by this flow, and nothing is sent to the server.
Variants
- Contract wallets. A signature that does not recover to the expected account is passed to the chain's ERC-1271 check (
isValidContractSignature), for both the issuer and the holder. The unit tests use a mocked chain answer; no real Safe signature has been run through this path. - Several contract sets. The
DisclosureRouterinsrc/services/index.tssends attestation and checks to the set whose registry the package or file names. - Older attestations. Re-attesting signs a fresh tree with new salts. An older attestation stays valid until the credential is revoked or otherwise stops being valid.
Limits
- Shown values are revealed. A hidden-value predicate needs a circuit, a contract and a ceremony; it was deferred (DEV-003, ADR-0074 in the handbook).
- Shown values are tied to the issuer's signed tree, not to the on-chain metadata commitment. The verify page says "as signed by the issuer".
- Disclosures of one credential are linkable (same credential id and root).
- A credential without a bound holder wallet can never reach Disclosure verified.
Source files
src/lib/disclosure/attributes.ts(tree, leaves, paths),attestation.ts(AttributeSettyped data),disclosure.ts(file format, parser, analysis),package-fields.ts,read-package.ts,vectors.jsonsrc/services/domain/disclosure-service.ts(attest, attestPackage, createDisclosure, verify)src/app/records/add/submit-record.ts(attestation after issue)src/app/student/MakeDisclosurePanel.tsx,src/app/student/make-disclosure-file.tssrc/app/dashboard/AddDisclosurePanel.tsxsrc/app/verify/DisclosurePanel.tsx,src/app/verify/DisclosureVerdictView.tsxdocs/specs/P2-S08-selective-disclosure.md(design and options)
Tests
- Unit:
src/lib/disclosure/attributes.test.ts,attestation.test.ts,disclosure.test.ts,package-fields.test.ts,vectors.test.ts;src/app/student/MakeDisclosurePanel.test.tsx,make-disclosure-file.test.ts;src/app/dashboard/AddDisclosurePanel.test.tsx;src/app/verify/DisclosurePanel.test.tsx,DisclosureVerdictView.test.tsx - End to end:
e2e/journeys/selective-disclosure.journey.ts(issue, link the student wallet, disclose the programme only, verify, replay under a new challenge, revoke and check again; a second test adds disclosure data to an earlier credential from the dashboard)