Skip to main content

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.

PropertyValue
Produced byencryptDocument in src/lib/doc-crypto/document.ts, called by DocumentService.upload and DocumentService.rotate
Consumed bydecryptDocument (browser), inspectContainerUpload (server, header only)
Stored atIPFS, pinned by the upload route, addressed by CID
Media type on uploadapplication/octet-stream, multipart field file, file name document.bin
Pin namecertichain- followed by the first 8 hex characters of the document hash
Format versionv: 1 in the header, plus the last byte of the magic (0x01)

Layout​

PartSizeRule
Magic6 bytesExactly 0x43 0x43 0x44 0x4f 0x43 0x01. Anything else is BAD_MAGIC.
Header length4 bytesBig-endian unsigned integer. More than 16,384 is HEADER_TOO_LARGE.
Headeras declaredUTF-8 JSON object. It must be byte-for-byte the RFC 8785 (JCS) form of itself, or the parser refuses it as NON_CANONICAL.
Ciphertextthe restThe body (without the 16-byte tag) is a whole number of 64 KiB blocks, at least one. Any other length is BAD_CIPHERTEXT.
Whole containerat most 4 MBMAX_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​

FieldTypeMeaningValidation
vnumberContainer format versionMust be 1. Another number raises UnsupportedVersionError ("This document uses a format version this app does not support").
issuerstringThe issuer wallet that encrypted the documentLowercase 0x address (40 hex characters).
documentHashstringSHA-256 of the plaintext file, the same value anchored on chainLowercase 0x plus 64 hex characters.
noncestringAES-GCM nonceBase64url of exactly 12 bytes.
recipientsarrayOne wrapped copy of the document key per recipient1 to 16 entries, no two with the same kid.

Each recipient entry has exactly three fields:

FieldTypeMeaningValidation
kidstringKey identifier: the first 8 bytes of SHA-256 of the recipient's X25519 public key16 lowercase hex characters.
encstringHPKE encapsulated key (the sender's ephemeral public key)Base64url of exactly 32 bytes.
ctstringThe 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.

PartSizeContent
Metadata length4 bytesBig-endian unsigned integer, at most 1,024
Metadataas declaredJSON with exactly name, type and size
Filesize bytesThe original file
Paddingup to the next 64 KiB boundaryZero 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 typeLeading bytes the file must start with
application/pdf25 50 44 46 2d (%PDF-)
image/png89 50 4e 47 0d 0a 1a 0a
image/jpegff 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​

PurposeAlgorithmBinding
Document encryptionAES-256-GCM, fresh random 32-byte key and 12-byte nonce per containerAdditional data = "certichain.doc.v1" ‖ issuer address (20 bytes) ‖ document hash (32 bytes)
Key wrappingHPKE base mode (RFC 9180): DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCMHPKE info = "certichain.dek.v1" ‖ document hash (32 bytes)
Recipient keyX25519 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:

  1. The wallet signs EIP-712 typed data on its own domain, { name: "Certichain keys", version: "1", chainId }, with the type EncryptionKey(address owner, uint32 keyVersion).
  2. 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.
  3. 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.
  4. The seed goes through the standard HPKE DeriveKeyPair for 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):

CheckResponse when it fails
Body parses as a container400 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 wallet403 FORBIDDEN ("The document was encrypted for a different wallet")
X-Document-SHA256 request header is present and well formed400 BAD_REQUEST
X-Document-SHA256 equals header.documentHash422 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 types
  • src/lib/doc-crypto/container.ts: encodeContainer, parseContainer, validateRecipient
  • src/lib/doc-crypto/payload.ts: buildPlaintext, parsePlaintext, assertSupportedFile
  • src/lib/doc-crypto/document.ts: encryptDocument, sealDocument, decryptDocument, recoverDocumentKey
  • src/lib/doc-crypto/binding.ts: additional data and HPKE info strings
  • src/lib/doc-crypto/wrap.ts: HPKE suite, wrapKey, unwrapKey
  • src/lib/doc-crypto/identity-key.ts, src/lib/doc-crypto/kid.ts: wallet-derived key and key id
  • src/services/documents/wallet-key-session.ts: two-signature derivation and session cache
  • src/services/documents/gateway-fetch.ts: bounded gateway download
  • src/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.ts
  • src/app/api/upload/route.container.test.ts
  • src/services/domain/document-service.test.ts
  • E2E: e2e/journeys/encrypted-documents.journey.ts