Skip to main content

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).
FunctionWhere it comes fromUsed by Certichain for
SHA-256NIST standard, available in every browser through Web CryptoThe documentHash of a file, the hash of an uploaded container (blobHash), key identifiers (kid), share code checksums, circuit file checks, hashed API keys
keccak-256The 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).

What a public document hash reveals

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.

LabelPurposeFile
certichain.subject.v1Subject and enrolment commitmentssrc/lib/commitments.ts
certichain.attr.v1A disclosure tree leaf that holds a fieldsrc/lib/disclosure/attributes.ts
certichain.attr.empty.v1A disclosure tree leaf for an absent fieldsrc/lib/disclosure/attributes.ts
certichain.doc.v1Associated data of the document encryptionsrc/lib/doc-crypto/binding.ts
certichain.dek.v1HPKE info string when wrapping a document keysrc/lib/doc-crypto/binding.ts
certichain.enc-key.v1HKDF salt for the wallet-derived encryption keysrc/lib/doc-crypto/identity-key.ts
certichain.badge-key.v1HKDF salt for the wallet-derived Open Badge keysrc/lib/vc/openbadges/standard/key.ts
certichain.access-identity.v1HKDF salt for the wallet-derived access identitysrc/lib/zk/access-identity.ts
certichain.share.v1Share code checksumsrc/lib/doc-crypto/share-code.ts
certichain.access.v1Access gate scopesrc/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 student salt follows the issuer key

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)));
}
FieldRequiredNotes
versionYesAlways 1 (DISPLAY_VERSION)
holderNameYesNon-empty, at most 500 characters, NFC
studentIdYesNon-empty, at most 500 characters, NFC
programmeNoAt most 500 characters
awardNoAt most 500 characters
awardedOnNoA 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​

ValueOn chainOpened byChecked in
subjectCommitmentYes, in the credential anchorstudentId and subjectSalt from the packagesrc/lib/credential-disclosure.ts, src/services/domain/presentation-service.ts, src/lib/vc/verify.ts
Enrolment commitmentYes, as the key of a holder bindingThe issuer's wallet, which can re-derive the saltsrc/services/domain/enrolment-service.ts
metadataHashYes, in the credential anchordisplay and metadataSalt from the packageSame 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 (canonicalSalted in src/lib/commitments.ts, using the canonicalize package).
  • The verification API hashes the JCS form of { data, meta } into the bodyHash it signs (bodyHashOf in src/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:

SlotField
0holderName
1studentId
2programme
3award
4awardedOn
5 to 7Reserved 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 attributes field (see Credential package).
  • The holder signs a DisclosureRequest naming 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.ts
  • src/lib/credential-disclosure.ts
  • src/services/domain/credential-service.ts, src/services/domain/enrolment-service.ts
  • src/lib/disclosure/attributes.ts, src/lib/disclosure/attestation.ts, src/lib/disclosure/disclosure.ts
  • src/lib/doc-crypto/binding.ts, src/lib/doc-crypto/document.ts
  • src/lib/api/envelope.ts

Tests​

  • src/lib/commitments.test.ts, src/lib/hash.test.ts, src/lib/credential-disclosure.test.ts
  • src/lib/disclosure/attributes.test.ts, src/lib/disclosure/attestation.test.ts, src/lib/disclosure/vectors.test.ts (fixed vectors in src/lib/disclosure/vectors.json)
  • src/services/domain/credential-service.test.ts, src/services/domain/enrolment-service.test.ts
  • blockchain/test/v03/NoPlaintext.test.ts (no plaintext personal data in the v0.3 contracts)