Document storage
A credential anchors the SHA-256 of its document, but not the document itself. On v0.3 the document is encrypted in the issuer's browser and only the encrypted container is stored, on IPFS through Pinata. The key that opens it goes into the holder's credential package. The issuer can open its own copies again with its wallet. Nobody else can read a stored copy without a key.
Who takes part
| Party | Holds |
|---|---|
| Issuer's wallet | Derives the issuer's encryption key from a signature; nothing is stored |
| Issuer's browser | Hashes and encrypts the file, keeps the fresh document key only until the package is built |
Upload route /api/upload | Checks the session and the container, pins it |
Documents route /api/documents | Finds and removes the issuer's own pins |
| Pinata and IPFS | The encrypted container, addressed by CID, tagged with documentHash and issuer |
| Holder | The credential package with cid and documentKey |
Preconditions
- v0.3 (
NEXT_PUBLIC_CONTRACT_VERSION=v03). The upload route chooses container mode from the same setting (readEncryptionMode). - Server settings for Pinata (a JWT with file read and write access), the session secret and the Redis store. Without them the routes answer that they are not configured.
- A plain wallet account. Contract wallets are refused before any prompt.
- For opening: a gateway. The browser tries
NEXT_PUBLIC_PINATA_GATEWAY_URLfirst, thenhttps://ipfs.ioandhttps://dweb.link, each with a 15 second timeout.
Storing a document
- The issuer chooses a file under Document file and presses Encrypt and upload on
/records/add(see Issuing). - The browser checks the type (PDF, PNG, JPEG), the size (3.5 MB) and the file's leading bytes, then hashes it. The hash is what the credential will anchor.
- The wallet derives the issuer's X25519 key from two identical
EncryptionKeysignatures. A fresh 32-byte document key encrypts the padded file with AES-256-GCM, bound to the issuer address and the hash. The document key is wrapped for the issuer's own key with HPKE. - The browser posts the container. The server accepts only a signed-in, registered issuer:
requireIssuerSessionchecks an IP budget, the session cookie, and re-checks the issuer role when the last check is old. It then applies a per-wallet budget (30 uploads per hour), refuses plaintext (PLAINTEXT_NOT_ACCEPTED), refuses a container whose header names another wallet (403), and requires the claimed hash to equal the header's hash (422 DOCUMENT_HASH_MISMATCH). - The container is pinned as
certichain-<8 hex>with key-valuesdocumentHashandissuer. When a second pin service is configured the route starts a mirror pin as well (see RPC and pinning). - The browser accepts the answer only when
blobHashmatches what it sent: "The server did not store the document as sent. Nothing was issued; try again."
The document key is returned to the page and goes into the package at issuance. It is never sent to the server.
What the server learns
The server and Pinata see an opaque container of a size rounded up to 64 KiB, the issuer's address and the document hash. They never see the file, its name, the key, or who the holder is. Document hashes are public on chain anyway.
Opening as the holder
The holder opens a document on /student, group Open and share your documents, panel Open a document: "Choose the credential package your university gave you. The document is downloaded and decrypted in this browser, and compared with the hash signed in the package. The package opens the document, so keep it private."
DocumentService.openFromPackage needs no wallet and no chain call. It refuses a package whose fields no longer hash to its credentialId, refuses a container that names another issuer, and refuses a file whose hash differs from the signed one. The result card says "Decrypted in this browser. It matches the hash signed in the package.", reports the issuer signature ("The issuer signature in the package is valid." or "The issuer signature could not be checked offline."), and links to Check that this credential is still active, because only the chain knows the status. Download saves the file under its original name.
| Outcome | Shown |
|---|---|
| Opened | The result card and Download |
No cid | "This package does not point to a stored document." |
cid but no documentKey (legacy) | "This document was stored unencrypted, before documents were protected." and Open the stored copy |
| Package changed | "This credential package is damaged, was changed after it was signed, or does not match the stored document, so it was not opened." |
| Gateways failed | "The document could not be downloaded from IPFS right now. Try again shortly." |
| Wrong key or changed copy | "The document could not be decrypted. The stored copy may have been changed, or this key does not belong to it." |
| Hash mismatch | "This document does not match the credential it belongs to, so it was not opened." |
If the document does not open, the panel offers Key update file ("Did the issuer send you a key update? Apply it first."), see Sharing. After a successful open it offers Share this document.
Opening as the issuer
On the issuer dashboard, a row's Open document action calls openIssuedDocument:
The issuer has no package, so it finds its copies by the anchored document hash. The documents route requires the same issuer session and rate limits (120 lookups per wallet per hour), and refuses a query that names another wallet than the session's. The browser tries each CID, newest first, until one opens with a key version whose kid is in the header. Each key version tried costs two signatures; at most four versions are tried. Messages: "No stored copy of this document was found for your account." and "Your account's keys do not open this document. It may have been stored with an earlier key or by another account."
Legacy v0.2 plaintext
On v0.2 (and for v0.3 credentials issued before encryption, whose packages are version 1), documents were pinned as plain files. The v0.2 upload route still exists for that mode: it accepts only PDF, PNG and JPEG (415 UNSUPPORTED_MEDIA_TYPE otherwise) and checks the claimed hash (422). A plaintext copy is readable by anyone who knows its CID. The decision on what to do with existing plaintext copies is recorded in the handbook (ADR-0065) and waits on the project owner.
What a document key protects, and what it does not
- Anyone with the package (or a capability link) can read the document. Treat both as secret.
- Replacing a key (see Sharing) stops the old key from opening future downloads once the old copies are removed. It cannot take back copies already saved.
- The issuer can always reopen its own copies with its wallet. A new issuer key (after a key retirement) cannot open copies sealed for the old key.
Code map
| Concern | File and function |
|---|---|
| Encrypt, store, open | src/services/domain/document-service.ts: encryptForUpload, upload, openFromPackage, openAsIssuer |
| Wallet key | src/services/documents/wallet-key-session.ts |
| Server calls | src/services/document-api.ts: uploadContainer, lookupDocumentCids, removeOtherCopies; src/services/upload.ts: withSignIn, signInForUpload |
| Gateway download | src/services/documents/gateway-fetch.ts |
| Crypto | src/lib/doc-crypto/ |
| Upload route | src/app/api/upload/route.ts, src/lib/server/upload-container.ts |
| Documents route | src/app/api/documents/route.ts |
| Session check | src/lib/server/issuer-session.ts, src/lib/server/limits.ts |
| Holder panel | src/app/student/OpenDocumentPanel.tsx, OpenedDocumentNotice.tsx |
| Issuer action | src/app/dashboard/open-issued-document.ts, IssuedCredentialsTable.tsx |
Tests
src/services/domain/document-service.test.ts,src/lib/doc-crypto/*.test.tssrc/app/api/upload/route.test.ts,route.container.test.ts,src/app/api/documents/route.test.tssrc/app/student/OpenDocumentPanel.test.tsx,src/app/dashboard/open-issued-document.test.ts- E2E:
e2e/journeys/encrypted-documents.journey.ts