Encrypted document container
On v0.3 every credential document (a PDF, PNG or JPEG) is encrypted in the issuer's browser before it leaves the device. The result is one binary container: a short magic prefix, a JSON header in canonical form, and an AES-256-GCM ciphertext of the padded file. The container is what the server pins to IPFS through Pinata. The server never sees the file or the key.
The flow that produces and opens containers is described in Document storage. The cryptography is introduced in Encryption.
| Property | Value |
|---|---|
| Produced by | encryptDocument in src/lib/doc-crypto/document.ts, called by DocumentService.upload and DocumentService.rotate |
| Consumed by | decryptDocument (browser), inspectContainerUpload (server, header only) |
| Stored at | IPFS, pinned by the upload route, addressed by CID |
| Media type on upload | application/octet-stream, multipart field file, file name document.bin |
| Pin name | certichain- followed by the first 8 hex characters of the document hash |
| Format version | v: 1 in the header, plus the last byte of the magic (0x01) |
Layout
| Part | Size | Rule |
|---|---|---|
| Magic | 6 bytes | Exactly 0x43 0x43 0x44 0x4f 0x43 0x01. Anything else is BAD_MAGIC. |
| Header length | 4 bytes | Big-endian unsigned integer. More than 16,384 is HEADER_TOO_LARGE. |
| Header | as declared | UTF-8 JSON object. It must be byte-for-byte the RFC 8785 (JCS) form of itself, or the parser refuses it as NON_CANONICAL. |
| Ciphertext | the rest | The body (without the 16-byte tag) is a whole number of 64 KiB blocks, at least one. Any other length is BAD_CIPHERTEXT. |
| Whole container | at most 4 MB | MAX_UPLOAD_BYTES (4 × 1024 × 1024) in src/lib/upload-limits.ts. Larger is TOO_LARGE. |
The canonical header means two parties that build the same header always produce the same bytes, so the header can be bound into other checks without ambiguity.
Header fields
| Field | Type | Meaning | Validation |
|---|---|---|---|
v | number | Container format version | Must be 1. Another number raises UnsupportedVersionError ("This document uses a format version this app does not support"). |
issuer | string | The issuer wallet that encrypted the document | Lowercase 0x address (40 hex characters). |
documentHash | string | SHA-256 of the plaintext file, the same value anchored on chain | Lowercase 0x plus 64 hex characters. |
nonce | string | AES-GCM nonce | Base64url of exactly 12 bytes. |
recipients | array | One wrapped copy of the document key per recipient | 1 to 16 entries, no two with the same kid. |
Each recipient entry has exactly three fields:
| Field | Type | Meaning | Validation |
|---|---|---|---|
kid | string | Key identifier: the first 8 bytes of SHA-256 of the recipient's X25519 public key | 16 lowercase hex characters. |
enc | string | HPKE encapsulated key (the sender's ephemeral public key) | Base64url of exactly 32 bytes. |
ct | string | The 32-byte document key sealed with HPKE (key plus 16-byte tag) | Base64url of exactly 48 bytes. |
Extra or missing fields in the header or in an entry are refused (BAD_FIELD, BAD_RECIPIENTS). Today the issue flow and the key replacement flow both write a single recipient: the issuer's own key.
The plaintext inside
Before encryption the file is wrapped in a small structure and padded, so the ciphertext length reveals only the file size rounded up to 64 KiB.
| Part | Size | Content |
|---|---|---|
| Metadata length | 4 bytes | Big-endian unsigned integer, at most 1,024 |
| Metadata | as declared | JSON with exactly name, type and size |
| File | size bytes | The original file |
| Padding | up to the next 64 KiB boundary | Zero bytes only |
The parser treats decrypted content as untrusted: only size says where the file ends, type must be on the allowlist and match the file's own signature, and the padding must be all zeros.
Allowed type | Leading bytes the file must start with |
|---|---|
application/pdf | 25 50 44 46 2d (%PDF-) |
image/png | 89 50 4e 47 0d 0a 1a 0a |
image/jpeg | ff d8 ff |
The file itself may be at most 3,670,016 bytes (MAX_FILE_BYTES, shown as "3.5 MB"). The 4 MB container cap leaves room for the header, padding and tag.
Cryptography
| Purpose | Algorithm | Binding |
|---|---|---|
| Document encryption | AES-256-GCM, fresh random 32-byte key and 12-byte nonce per container | Additional data = "certichain.doc.v1" ‖ issuer address (20 bytes) ‖ document hash (32 bytes) |
| Key wrapping | HPKE base mode (RFC 9180): DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM | HPKE info = "certichain.dek.v1" ‖ document hash (32 bytes) |
| Recipient key | X25519 key pair derived from a wallet signature (see below) | kid = first 8 bytes of SHA-256 of the public key |
The additional data ties the ciphertext to one issuer and one document. Moving a container to another credential, or changing the issuer or hash in its header, makes decryption fail. The HPKE info string ties each wrapped key to the same document hash.
Where the recipient key comes from
No private key is stored anywhere. The wallet derives it on demand:
- The wallet signs EIP-712 typed data on its own domain,
{ name: "Certichain keys", version: "1", chainId }, with the typeEncryptionKey(address owner, uint32 keyVersion). - The app asks for the same signature twice and refuses to continue if the two differ (
NonRepeatableSignatureError: "This wallet does not sign the same message the same way twice, so it cannot protect documents. Use a standard wallet account."). It also checks that the signature recovers to the connected account. - The signature is put into one canonical byte form (wallets differ on 64 or 65 bytes and on
v) and fed to HKDF-SHA256 with salt"certichain.enc-key.v1"and info = owner address (20 bytes) ‖ key version (4 bytes, big-endian), giving a 32-byte seed. - The seed goes through the standard HPKE
DeriveKeyPairfor X25519.
The key is cached in memory per chain and account for the session (WalletKeySession in src/services/documents/wallet-key-session.ts). Contract wallets are refused before any prompt (ContractWalletDocumentError), because their signatures are not guaranteed to repeat. When opening, the issuer's app tries key versions 1 to 4 (each version costs two signatures) until one has a kid in the header; new containers use version 1.
How a container is checked
The server checks only the outside (inspectContainerUpload in src/lib/server/upload-container.ts):
| Check | Response when it fails |
|---|---|
| Body parses as a container | 400 PLAINTEXT_NOT_ACCEPTED ("Documents must be encrypted before upload") when the bytes look like a PDF, PNG or JPEG, otherwise 400 INVALID_CONTAINER |
header.issuer equals the signed-in wallet | 403 FORBIDDEN ("The document was encrypted for a different wallet") |
X-Document-SHA256 request header is present and well formed | 400 BAD_REQUEST |
X-Document-SHA256 equals header.documentHash | 422 DOCUMENT_HASH_MISMATCH |
On success the route answers with cid, documentHash and blobHash (SHA-256 of the container bytes it pinned). The browser compares blobHash with the hash of what it sent and refuses a mismatch (UploadIntegrityError: "The server did not store the document as sent. Nothing was issued; try again."). See Upload and documents.
The browser, when it fetches a container from a gateway, reads at most 4 MB, parses it before returning it, and then decrypts. The final hash must match both the header and the hash the credential states.
Example header
Fake values. In a real container the header is one line with no spaces, keys in sorted order.
{
"documentHash": "0x9f2c4d0000000000000000000000000000000000000000000000000000000001",
"issuer": "0x1111111111111111111111111111111111111111",
"nonce": "AAAAAAAAAAAAAAAA",
"recipients": [
{
"ct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"enc": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"kid": "0a1b2c3d4e5f6071"
}
],
"v": 1
}
Versioning
The magic ends with 0x01 and the header carries v: 1. A parser that sees another v stops with a clear message instead of guessing. A future format would raise both. src/lib/doc-crypto/container-versions.test.ts pins the version behaviour.
Legacy v0.2 documents
On v0.2 (NEXT_PUBLIC_CONTRACT_VERSION not v03) the upload route stores the plain file, after checking its type and that its SHA-256 matches X-Document-SHA256. Such documents are not containers and are readable by anyone with the CID. A v0.3 package without a documentKey but with a cid is treated as such a legacy plaintext document (see Credential package).
Source files
src/lib/doc-crypto/constants.ts: magic, limits, allowed typessrc/lib/doc-crypto/container.ts:encodeContainer,parseContainer,validateRecipientsrc/lib/doc-crypto/payload.ts:buildPlaintext,parsePlaintext,assertSupportedFilesrc/lib/doc-crypto/document.ts:encryptDocument,sealDocument,decryptDocument,recoverDocumentKeysrc/lib/doc-crypto/binding.ts: additional data and HPKE info stringssrc/lib/doc-crypto/wrap.ts: HPKE suite,wrapKey,unwrapKeysrc/lib/doc-crypto/identity-key.ts,src/lib/doc-crypto/kid.ts: wallet-derived key and key idsrc/services/documents/wallet-key-session.ts: two-signature derivation and session cachesrc/services/documents/gateway-fetch.ts: bounded gateway downloadsrc/lib/server/upload-container.ts,src/app/api/upload/route.ts: server-side inspection and pinning
Tests
src/lib/doc-crypto/container.test.ts,container-versions.test.ts,payload.test.ts,document.test.ts,wrap.test.ts,identity-key.test.ts,boundary.test.ts,access-matrix.test.ts,constants.test.ts,encoding.test.tssrc/app/api/upload/route.container.test.tssrc/services/domain/document-service.test.ts- E2E:
e2e/journeys/encrypted-documents.journey.ts