Encryption
The document behind a credential (the degree certificate, the transcript) is personal data. Certichain encrypts it in the issuer's browser before it leaves the device, stores only the encrypted form on IPFS, and gives the key to the holder inside their credential package. The server and the pinning service never see a plaintext document or a key. This page explains the building blocks and how they fit.
Symmetric and public-key encryption
- Symmetric encryption uses one secret key to encrypt and decrypt. It is fast and suits large data such as files.
- Public-key encryption uses a key pair: anyone can encrypt to the public key, only the private key decrypts. It suits small secrets sent to a specific person.
Certichain uses the usual combination: each document is encrypted once with a fresh symmetric document key, and that small key is then encrypted (wrapped) to each person who should be able to open the document.
AES-256-GCM and associated data
AES-256-GCM is a symmetric authenticated encryption scheme (AEAD). Besides hiding the data it produces a 16-byte tag; decryption fails if a single bit of the ciphertext, the nonce or the associated data has changed. Associated data is not encrypted but is covered by the tag, so it binds the ciphertext to a context.
| Parameter | Value | Source |
|---|---|---|
| Key | 32 random bytes, fresh per document | KEY_BYTES in src/lib/doc-crypto/constants.ts |
| Nonce | 12 random bytes | NONCE_BYTES |
| Tag | 16 bytes | GCM_TAG_BYTES |
| Associated data | "certichain.doc.v1" followed by the issuer address (20 bytes) and the document hash (32 bytes) | documentAad in src/lib/doc-crypto/binding.ts |
/** What the document ciphertext is bound to, so it cannot be moved to another issuer's or document's credential. */
export function documentAad(issuer: string, documentHash: string): Uint8Array {
return concat(DOC_DOMAIN, addressBytes(issuer), hashBytes(documentHash));
}
Because the issuer and the document hash are in the associated data, a container cannot be relabelled as another issuer's document or another credential's file: decryption would fail. After decryption the browser also hashes the recovered file and requires it to equal both the container's documentHash and the hash the credential states (decryptDocument in src/lib/doc-crypto/document.ts).
The browser's own Web Crypto API (crypto.subtle) performs AES-GCM; no cryptography library is shipped for it.
Padding
Ciphertext is as long as the plaintext, so an observer could guess what kind of document it is from its size. Before encryption the file is laid out as a short metadata block (file name, type, size), the file bytes, then zeros up to the next multiple of 64 KiB (PAD_BUCKET = 65536, buildPlaintext in src/lib/doc-crypto/payload.ts). An observer learns only the size rounded up to 64 KiB. On opening, the padding must be all zeros or the content is refused.
Only PDF, PNG and JPEG files are accepted, and each must start with its own file signature (magic bytes). The largest file is 3,670,016 bytes, shown to users as "3.5 MB" (MAX_FILE_BYTES), which keeps the encrypted container under the 4 MB upload limit.
HPKE: wrapping the document key
HPKE (Hybrid Public Key Encryption, RFC 9180) is a standard way to encrypt a small message to a public key. It combines a key agreement (here X25519), a key derivation function and an AEAD, and binds the result to an info string.
| HPKE component | Choice |
|---|---|
| Mode | Base |
| KEM | DHKEM(X25519, HKDF-SHA256) |
| KDF | HKDF-SHA256 |
| AEAD | AES-256-GCM |
info | "certichain.dek.v1" followed by the document hash (keyInfo in src/lib/doc-crypto/binding.ts) |
wrapKey in src/lib/doc-crypto/wrap.ts seals the 32-byte document key to one recipient. The result is a recipient entry with three fields:
kid: a pseudonymous key id, the first 8 bytes of SHA-256 of the recipient's public key, written as 16 hex characters (kidOfinsrc/lib/doc-crypto/kid.ts);enc: the sender's ephemeral public key;ct: the wrapped key, 48 bytes (32-byte key and 16-byte tag).
Because the document hash is in the info string, a wrapped key taken from one document's container cannot be used for another. The implementation uses the @hpke/core and @hpke/dhkem-x25519 packages.
Keys derived from wallet signatures
Users should not have to back up yet another key file. Certichain therefore derives each person's encryption key pair from their wallet. The wallet signs fixed typed data, and the signature is the input key material:
- Nothing is stored. Signing the same typed data again recovers the same key, which is why the app checks that the wallet signs deterministically (see Signatures).
- The keys live in memory for the page session in
WalletKeySession(src/services/documents/wallet-key-session.ts) and are dropped as soon as the wallet account or chain changes. - The typed data carries a
keyVersion. Documents are encrypted with version 1 (ACTIVE_KEY_VERSION); when opening, the issuer's browser tries up to 4 versions (DEFAULT_MAX_KEY_VERSIONSinsrc/services/domain/document-service.ts), which leaves room for a future rotation. - A contract wallet cannot do this and is told so.
How a document is encrypted at issuance
At issuance the only recipient in the container header is the issuer's own key, so the issuer can open the document later from the dashboard. The holder does not need a key pair: the raw document key is written into their credential package as documentKey, base64url encoded. Whoever holds the package can read the document, and the package is never uploaded anywhere.
The container layout (magic bytes, header length, canonical JSON header with v, issuer, documentHash, nonce and up to 16 recipient entries, then the ciphertext) is described in Encrypted container. The flow is in Document storage.
Sharing a document
A holder can give a third party (an employer, another university) access to the document in three ways. Each one moves only the document key; the container on IPFS stays the same.
| Method | What the recipient needs | What travels | Who can read |
|---|---|---|---|
| Share code and grant file | A wallet, to produce their share code | The recipient sends a share code; the holder sends back a grant file | Only the recipient's wallet key |
| Capability link | Nothing | A link with the key in its fragment | Anyone who has the link |
| The package itself | Nothing | The full credential package | Anyone who has the package, and they also see the holder details |
Share codes
A share code is the recipient's public key in a form that survives copying and pasting: ccshare1_ followed by base64url of a version byte, the 32-byte X25519 public key and a 4-byte checksum (the first 4 bytes of SHA-256 over "certichain.share.v1", the version and the key). The checksum catches typing mistakes; it does not prove whose key it is. For that, both people compare the fingerprint, the kid written in groups of four characters, over another channel (src/lib/doc-crypto/share-code.ts).
Grant files
The holder's browser reads the package, wraps its document key to the recipient's public key with HPKE (same suite and info as above) and saves a grant file. The grant carries the signed credential fields, the CID and the wrapped key; it carries no holder details and no key in the clear, and any extra field is refused when it is read (src/lib/doc-crypto/grant.ts).
When the recipient opens it, the app first checks the chain: the credential must exist and name the grant's issuer and document hash. Only then is the wallet asked for the key signature (DocumentService.openGrant). The full flow is in Sharing.
Capability links
A capability link carries everything needed to open the document in its URL fragment:
https://<site>/open#<credentialId>.<cid>.<documentKey>
Browsers never send the fragment (the part after #) to the server, so the key does not appear in server logs. The /open page is served with Referrer-Policy: no-referrer and Cache-Control: no-store (OPEN_PAGE_HEADERS in src/lib/security-headers.ts). The page reads the credential from the chain, fetches the container, checks that it names the credential's issuer, decrypts and checks the file hash (DocumentService.openCapability).
Anyone who obtains a capability link can read the document. Sharing a link cannot be undone except by replacing the document key, described next.
Replacing a document key
If a key may have leaked, the issuer can re-encrypt the same file under a fresh key (DocumentService.rotate):
- The issuer's browser opens the stored copy, encrypts the same file again with a new document key and stores it as a new container with a new CID. Nothing is removed yet, so a failure leaves the old copy working.
- The issuer gives the holder a key update file: the credential id, the new CID and the new document key (
src/lib/doc-crypto/key-update.ts). It is as secret as the key. - Once the holder has the update, the issuer asks the server to remove its older copies. The newest copy is never removed, and the server does not delete copies that other credentials still use.
The anchored documentHash does not change, because the file is the same.
A recipient who already downloaded the document, or kept the old key and an old copy of the container, can still read what they had. IPFS nodes and gateways outside Certichain's control may also keep old copies for a while. Rotation protects only against future use of the old key with new copies.
What the encryption protects and what it does not
| Protected | Not protected |
|---|---|
| The document content from the server, the pinning service, gateways and anyone who finds the CID | Anyone who holds the package, a grant's private key or a capability link |
| Swapping or editing a stored container (the tag or the hash check fails, so the result is a refusal, not a forged document) | The file size, rounded up to 64 KiB, and the issuer address and document hash in the header |
| Re-using a wrapped key for another document | A compromised page or browser extension that runs in the same tab while the document is open |
A phishing site that persuades a user to sign the EncryptionKey typed data for it |
The Threat model discusses these in context. X25519 is not resistant to future quantum computers; the container format is versioned so that the suite can change.
Source files
src/lib/doc-crypto/constants.ts,binding.ts,payload.ts,document.ts,container.ts,wrap.ts,kid.tssrc/lib/doc-crypto/identity-key.ts,src/services/documents/wallet-key-session.tssrc/lib/doc-crypto/share-code.ts,grant.ts,capability.ts,key-update.tssrc/services/domain/document-service.tssrc/lib/upload-limits.ts,src/lib/security-headers.tssrc/lib/server/upload-container.ts,src/app/api/upload/route.ts
Tests
src/lib/doc-crypto/document.test.ts,container.test.ts,container-versions.test.ts,payload.test.ts,wrap.test.ts,constants.test.ts,boundary.test.ts,access-matrix.test.tssrc/lib/doc-crypto/identity-key.test.ts,share-code.test.ts,grant.test.ts,capability.test.ts,key-update.test.tssrc/services/domain/document-service.test.ts,document-service.sharing.test.ts,document-service.rotation.test.tssrc/app/api/upload/route.container.test.ts,src/lib/server/upload-container.test.ts- E2E journeys
e2e/journeys/encrypted-documents.journey.ts,share-and-replace.journey.ts,replace-shared-file.journey.ts