Hashing and commitments
Certichain never writes a document, a name or a student id to the chain. It writes fingerprints of them. This page explains the hash functions behind those fingerprints, why some are salted, and how the selective disclosure tree lets a holder reveal one field without the others.
Hash functions
A cryptographic hash function maps any input to a fixed-size output (32 bytes for both functions below) such that:
- the same input always gives the same output;
- changing one bit of the input changes the output completely;
- nobody can find an input for a given output (preimage resistance) or two inputs with the same output (collision resistance).
| Function | Where it comes from | Used by Certichain for |
|---|---|---|
| SHA-256 | NIST standard, available in every browser through Web Crypto | The documentHash of a file, the hash of an uploaded container (blobHash), key identifiers (kid), share code checksums, circuit file checks, hashed API keys |
| keccak-256 | The hash built into Ethereum (often written "keccak256" or "SHA3" in Solidity) | Credential ids (EIP-712 digests), subject and enrolment commitments, metadata hashes, the disclosure tree, status note hashes, the access gate scope, the API body hash |
The document hash
documentHash is the SHA-256 of the exact bytes of the issued file (a PDF, PNG or JPEG). It is computed in the issuer's browser, anchored in the credential, and recomputed in the verifier's browser when someone checks a file against a credential. Encryption never changes it: the encrypted container carries the same documentHash in its header, and decryption checks that the recovered file still hashes to it (src/lib/doc-crypto/document.ts).
The upload route also recomputes the hash on the server and refuses a mismatch, so the stored file and the anchored hash cannot drift apart (see Upload and documents).
The hash does not reveal the document. It does let someone who already holds a candidate file confirm that this exact file was issued. That is the purpose of the hash, and it is why the document itself is encrypted rather than merely hashed.
Domain separation
The same hash function serves many purposes in one system. If two purposes hashed the same kind of input, a value made for one could be presented as the other. Domain separation prevents this by prefixing every hashed input with a fixed label for its purpose.
| Label | Purpose | File |
|---|---|---|
certichain.subject.v1 | Subject and enrolment commitments | src/lib/commitments.ts |
certichain.attr.v1 | A disclosure tree leaf that holds a field | src/lib/disclosure/attributes.ts |
certichain.attr.empty.v1 | A disclosure tree leaf for an absent field | src/lib/disclosure/attributes.ts |
certichain.doc.v1 | Associated data of the document encryption | src/lib/doc-crypto/binding.ts |
certichain.dek.v1 | HPKE info string when wrapping a document key | src/lib/doc-crypto/binding.ts |
certichain.enc-key.v1 | HKDF salt for the wallet-derived encryption key | src/lib/doc-crypto/identity-key.ts |
certichain.badge-key.v1 | HKDF salt for the wallet-derived Open Badge key | src/lib/vc/openbadges/standard/key.ts |
certichain.access-identity.v1 | HKDF salt for the wallet-derived access identity | src/lib/zk/access-identity.ts |
certichain.share.v1 | Share code checksum | src/lib/doc-crypto/share-code.ts |
certichain.access.v1 | Access gate scope | src/lib/zk/scope.ts, blockchain/contracts/zk/CertichainAccessGate.sol |
EIP-712 typed data does the same job for signatures through its domain and type names (see Signatures).
Commitments
A commitment is a value that fixes some data without revealing it, and that can later be opened by showing the data. A good commitment is:
- binding: once published, it cannot be opened to different data;
- hiding: it reveals nothing about the data until it is opened.
A bare hash is binding but not always hiding. If the data is guessable (a name from a class list, a student id from a known range), anyone can hash every guess and compare. Adding a random 32-byte salt to the input makes guessing useless: the salt has 2^256 possible values. The salt is kept off chain with whoever should be able to open the commitment.
Subject commitment
/** keccak256(abi.encode("certichain.subject.v1", issuer, normalise(studentId), salt)). */
export function subjectCommitment(issuer: string, studentId: string, salt: string): string {
- The student id is normalised first: Unicode NFC, trimmed, upper case (
normaliseStudentId). " s-1001 " and "S-1001" commit to the same value. - The issuer address is part of the input, so the same student id at two institutions gives unrelated commitments.
- The salt is 32 bytes. Which salt is used depends on enrolment, described next.
Enrolment commitment and the stable student salt
To link a wallet to a student (see Enrolment), the HolderRegistry needs a value that stays the same for that student across all of their credentials. Certichain uses the subject commitment with a stable student salt for this, called the enrolment commitment.
The issuer does not store these salts. The browser derives one by asking the issuer's wallet to sign typed data that names the issuer and the student id, then hashing the signature:
export async function deriveStudentSalt(issuer, studentId, signTypedData): Promise<string> {
const { domain, types, value } = studentSaltTypedData(issuer, studentId);
return keccak256(await signTypedData(domain, types, value));
}
The typed data uses its own domain, Certichain Student Salt, with no chain id, so the salt is the same on every network. Because a standard wallet signs deterministically (RFC 6979, see Signatures), signing again later reproduces the same salt.
When the issue form links a credential to an enrolled student, the credential's subject commitment uses this stable salt, so it equals the enrolment commitment and the chain can answer "which wallet holds this credential" with holderOf(issuer, subjectCommitment). Otherwise the subject salt is fresh and random (CredentialService.issue in src/services/domain/credential-service.ts).
The salt is a function of the issuer wallet's signature. If an institution moves to a different wallet key, every student's salt and enrolment commitment changes with it. The code comments in src/lib/commitments.ts state this, and the Key management page covers what it means for key rotation.
Metadata hash
The human-readable credential data (the display fields) is hashed with its own salt:
export function metadataHash(display: CredentialDisplay, salt: string): string {
return keccak256(toUtf8Bytes(canonicalSalted(display, salt)));
}
| Field | Required | Notes |
|---|---|---|
version | Yes | Always 1 (DISPLAY_VERSION) |
holderName | Yes | Non-empty, at most 500 characters, NFC |
studentId | Yes | Non-empty, at most 500 characters, NFC |
programme | No | At most 500 characters |
award | No | At most 500 characters |
awardedOn | No | A calendar date written YYYY-MM-DD |
The metadata salt is always fresh for every credential, even for an enrolled student. The reason is stated in credential-service.ts: if two credentials shared a metadata salt, the package of one could be used to test guesses against the metadata hash of the other.
In Certichain
| Value | On chain | Opened by | Checked in |
|---|---|---|---|
subjectCommitment | Yes, in the credential anchor | studentId and subjectSalt from the package | src/lib/credential-disclosure.ts, src/services/domain/presentation-service.ts, src/lib/vc/verify.ts |
| Enrolment commitment | Yes, as the key of a holder binding | The issuer's wallet, which can re-derive the salt | src/services/domain/enrolment-service.ts |
metadataHash | Yes, in the credential anchor | display and metadataSalt from the package | Same files as the subject commitment |
On the verify page, a holder who presents the package lets the verifier recompute both values and see that the name and id they were shown are the ones the issuer signed.
Canonical JSON (RFC 8785)
JSON allows the same data to be written many ways: key order, spacing and number formats vary. Hashing JSON is only reliable if everyone serialises it identically. RFC 8785, the JSON Canonicalization Scheme (JCS), defines one exact form: keys sorted, no whitespace, fixed number and string encoding.
In Certichain
- The metadata hash hashes the JCS form of the display fields plus the salt (
canonicalSaltedinsrc/lib/commitments.ts, using thecanonicalizepackage). - The verification API hashes the JCS form of
{ data, meta }into thebodyHashit signs (bodyHashOfinsrc/lib/api/envelope.ts).
The selective disclosure field tree
Opening the metadata hash reveals every display field at once. To show only some fields (for example "award" without "studentId"), the issuer also commits to the fields one by one in a small Merkle tree and signs its root.
A Merkle tree hashes items in pairs, then pairs of those hashes, up to a single root. To prove one item belongs to the root you need only the sibling hashes on the path from that item to the root (a Merkle proof), not the other items themselves.
The Certichain tree (src/lib/disclosure/attributes.ts) has exactly 8 leaves and depth 3:
| Slot | Field |
|---|---|
| 0 | holderName |
| 1 | studentId |
| 2 | programme |
| 3 | award |
| 4 | awardedOn |
| 5 to 7 | Reserved for later versions |
- Each slot has its own random 32-byte salt (
generateAttributeSalts). - A slot that holds a field:
keccak256(abi.encode("certichain.attr.v1", index, name, value, salt)), with the value in NFC. - A slot with no field, or a reserved slot:
keccak256(abi.encode("certichain.attr.empty.v1", index, salt)). Because empty slots are salted too, a verifier cannot tell an absent field from a hidden one. - A parent is
keccak256(left || right), in slot order.
To show slot 3, the holder gives the value, its salt and three siblings (slot 2's leaf, the hash of slots 0 and 1, the hash of slots 4 to 7). The verifier rebuilds the root with rootFromPath and checks that the issuer signed that root.
In Certichain
- The issuer signs the root with EIP-712 type
AttributeSet(bytes32 credentialId, bytes32 attributesRoot, uint32 version)on the credential's own domain (src/lib/disclosure/attestation.ts). The root is not on chain; the signature ties it to the credential id, which is. - The package carries the 8 salts, the root and the attestation in its
attributesfield (see Credential package). - The holder signs a
DisclosureRequestnaming the fields shown and the verifier's challenge, which stops a disclosure file from being replayed to another verifier. The full flow is in Selective disclosure.
Source files
src/lib/hash.ts(SHA-256 helpers)src/lib/commitments.tssrc/lib/credential-disclosure.tssrc/services/domain/credential-service.ts,src/services/domain/enrolment-service.tssrc/lib/disclosure/attributes.ts,src/lib/disclosure/attestation.ts,src/lib/disclosure/disclosure.tssrc/lib/doc-crypto/binding.ts,src/lib/doc-crypto/document.tssrc/lib/api/envelope.ts
Tests
src/lib/commitments.test.ts,src/lib/hash.test.ts,src/lib/credential-disclosure.test.tssrc/lib/disclosure/attributes.test.ts,src/lib/disclosure/attestation.test.ts,src/lib/disclosure/vectors.test.ts(fixed vectors insrc/lib/disclosure/vectors.json)src/services/domain/credential-service.test.ts,src/services/domain/enrolment-service.test.tsblockchain/test/v03/NoPlaintext.test.ts(no plaintext personal data in the v0.3 contracts)