Credential package
The credential package is the portable proof a holder keeps. It carries everything needed to check a v0.3 credential offline (the fields the issuer signed and the EIP-712 signature), the salts that prove the holder's details against the on-chain commitments, and, for an encrypted document, the key that opens it.
A version 2 package with a documentKey opens the stored document. Anyone who holds the file can read the document and see the holder's name and student ID. The issue page says "Keep this file private: it opens the document."
| Property | Value |
|---|---|
| File name | credential- followed by the first 8 hex characters of the credential id, .json (bulk import names them 0001-<externalId>.json inside a zip) |
| Current version | 2 (PACKAGE_VERSION); version 1 is still read |
| Produced by | buildPackage in src/lib/credential-package.ts, called by CredentialService.issue |
| Read by | parsePackage in the same file |
| Size accepted by upload fields | 256 KiB (MAX_PACKAGE_BYTES in the student and dashboard panels) |
Where it is produced and used
Fields
| Field | Type | Required | Meaning | Validation on read |
|---|---|---|---|---|
version | number | yes | Package version | 2 or 1. A version 1 package may not carry documentKey. |
domain.name | string | yes | EIP-712 domain name, "Certichain" | Non-empty string |
domain.version | string | yes | EIP-712 domain version, "1" | Non-empty string |
domain.chainId | string | yes | Chain id as a decimal string | Decimal, at most 2^256 − 1 |
domain.verifyingContract | string | yes | Address of the CredentialRegistry that anchored it | Address |
credential.issuer | string | yes | Issuer wallet that signed | Address |
credential.subjectCommitment | string | yes | Salted commitment to the student id | 32-byte hex |
credential.documentHash | string | yes | SHA-256 of the plaintext document | 32-byte hex |
credential.metadataHash | string | yes | Salted hash of the display data | 32-byte hex |
credential.recordType | number | yes | Record type id (Record types) | Integer up to 2^32 − 1 |
credential.issuedAt | number | yes | Unix seconds when the issuer signed | Safe integer |
credential.validUntil | number | yes | Unix seconds of expiry, 0 for none (the app always writes 0) | Safe integer |
credential.nonce | string | yes | Random 256-bit nonce as a decimal string | Decimal |
signature | string | yes | The issuer's EIP-712 signature over credential | 64 or 65 byte hex signature |
credentialId | string | yes | The EIP-712 digest of domain and credential, which is the on-chain id | 32-byte hex |
cid | string | no | IPFS CID of the stored document (container on v0.3) | Kept when a non-empty string |
documentKey | string | no | Base64url of the 32-byte AES key that opens the container | Exactly 32 bytes; version 2 only |
display | object | no | The human-readable details (below) | validateDisplay |
subjectSalt | string | no | Salt that proves subjectCommitment | 32-byte hex |
metadataSalt | string | no | Salt that proves metadataHash | 32-byte hex |
attributes | object | no | Selective disclosure data: version, 8 salts, root, attestation | Exactly those keys (Disclosure file) |
The signed part (domain, credential, signature, credentialId) has the same shape and is read by the same function (readSignedFields) as in the shared document file. A file with a type field is refused with a message that names what it looks like instead ("This is a shared document file, not a credential package."). Other unknown fields are dropped when the package is read.
display
| Field | Required | Rule |
|---|---|---|
version | yes | 1 |
holderName | yes | Non-empty, at most 500 characters, normalised to Unicode NFC |
studentId | yes | Non-empty, at most 500 characters, NFC |
programme | no | Non-empty, at most 500 characters, NFC |
award | no | Non-empty, at most 500 characters, NFC |
awardedOn | no | A calendar date written YYYY-MM-DD (display only; the credential is dated by issuedAt) |
How the hashes are built
subjectCommitment = keccak256(abi.encode(
"certichain.subject.v1", issuer, normalise(studentId), subjectSalt))
normalise(studentId) = NFC, trim, upper case
metadataHash = keccak256(utf8( JCS({ ...display, salt: metadataSalt }) ))
credentialId = EIP-712 hash of credential under
domain { name: "Certichain", version: "1", chainId, verifyingContract }
subjectSaltis the issuer's stable student salt when the credential was issued with delivery ticked, so the commitment equals the student's enrolment commitment and the credential shows in their wallet. Otherwise it is fresh. See Enrolment.metadataSaltis always fresh, so one package cannot be used to test guesses against another credential's metadata hash.- JCS is RFC 8785 canonical JSON. See Hashing and commitments.
Checks a reader can make
| Check | Needs | Function |
|---|---|---|
The fields still hash to credentialId | Nothing | verifyPackageOffline(pkg).digestMatches |
The signature recovers to credential.issuer | Nothing | verifyPackageOffline(pkg).signature is "valid"; "needs-contract-check" means the issuer may be an ERC-1271 contract wallet |
| The anchor on chain has the same fields | RPC | CredentialService.checkPackage (verify page) or verifyOnChain (script) |
| Name and student id match the commitments | RPC | checkDisclosure inside checkPackage |
| The issuer is registered | RPC | The script's issuerRegistered line |
| Status (revoked, suspended, superseded) | RPC | Only the chain answers this, never the package |
The verify page panel Check holder details returns one of: "Holder details match the on-chain commitments", "Holder details do NOT match the on-chain commitments. The package may have been altered.", "This package does not match this credential on-chain.", "This package belongs to a different credential.", "This package does not include holder details, so only the document and signature can be checked." See Verification.
Versions
| Version | Introduced with | Difference |
|---|---|---|
| 1 | v0.3 issuance before document encryption | cid points at a plaintext document; documentKey is not allowed |
| 2 | Client-side encryption | May carry documentKey. Applying a key update raises a version 1 package to 2. |
A package with cid but no documentKey opens as legacy plaintext: the student page says "This document was stored unencrypted, before documents were protected." and offers Open the stored copy through the configured gateway. A package with no cid has no document ("This package does not point to a stored document."). Bulk import rows without a document still anchor a hash: the SHA-256 of zero bytes, 0xe3b0c442...b855.
Example
Fake values throughout.
{
"version": 2,
"domain": {
"name": "Certichain",
"version": "1",
"chainId": "11155111",
"verifyingContract": "0x2222222222222222222222222222222222222222"
},
"credential": {
"issuer": "0x1111111111111111111111111111111111111111",
"subjectCommitment": "0x0000000000000000000000000000000000000000000000000000000000000a01",
"documentHash": "0x9f2c4d0000000000000000000000000000000000000000000000000000000001",
"metadataHash": "0x0000000000000000000000000000000000000000000000000000000000000b02",
"recordType": 1,
"issuedAt": 1792800000,
"validUntil": 0,
"nonce": "73829174628374619283746192837461928374619283746192837461928374"
},
"signature": "0x<65-byte signature>",
"credentialId": "0x0000000000000000000000000000000000000000000000000000000000000c03",
"cid": "bafybeiexampleexampleexampleexampleexampleexampleexampleexa",
"documentKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"display": {
"version": 1,
"holderName": "Asha Rai",
"studentId": "S-1001",
"programme": "BSc Computer Science",
"award": "First Class",
"awardedOn": "2026-07-15"
},
"subjectSalt": "0x0000000000000000000000000000000000000000000000000000000000000d04",
"metadataSalt": "0x0000000000000000000000000000000000000000000000000000000000000e05"
}
Other formats made from a package
The package is the source for the verifiable credential and Open Badge exports, the disclosure file, the shared document file and capability link, and the phone wallet's compact codes. None of them carries the documentKey except the capability link, which carries the key on purpose.
Source files
src/lib/credential-package.ts:CredentialPackage,buildPackage,parsePackage,readSignedFields,verifyPackageOfflinesrc/lib/eip712.ts: domain,CREDENTIAL_TYPES,credentialIdsrc/lib/commitments.ts:subjectCommitment,deriveStudentSalt,validateDisplay,metadataHashsrc/lib/credential-disclosure.ts: commitment checks used bycheckPackagesrc/lib/doc-crypto/key-update.ts:applyKeyUpdatesrc/services/domain/credential-service.ts:issue,checkPackageblockchain/scripts/verify-credential.ts,blockchain/scripts/lib/verify-credential.ts: command line check
Tests
src/lib/credential-package.test.ts,src/lib/eip712.test.ts,src/lib/commitments.test.ts,src/lib/credential-disclosure.test.ts,src/lib/doc-crypto/key-update.test.tssrc/services/domain/credential-service.test.tsblockchain/test/v03/VerifyCredential.test.ts- E2E:
e2e/journeys/issue-and-verify.journey.ts,e2e/journeys/encrypted-documents.journey.ts