Sharing documents
A holder who has opened a document with their credential package can give someone else access to it without any server holding a key. There are two ways:
| Way | Reaches | Can be stopped |
|---|---|---|
| Shared document file (a grant), sealed to the recipient's share code | Only the wallet that made that share code | Not for what was already opened; the issuer can replace the key for the future |
| Capability link with the key in the URL fragment | Anyone who gets the link | Only by the issuer replacing the key |
When a key must be withdrawn, the issuer re-encrypts the document under a new key, gives the holder a key update file, and removes the old stored copies. The file formats are described in Sharing files and codes.
Sharing gives someone the document. To prove the credential itself, the holder gives the credential id or link (anyone can verify it), the package (to prove name and ID), or a disclosure file for chosen fields.
Preconditions
- v0.3 and a version 2 package with a
cidand adocumentKey. A package without a protected document gives "This credential has no protected document to share. Documents stored before encryption was added cannot be shared this way." - The holder has opened the document on
/student(Open a document), which shows Share this document after a successful open. - For a grant, the recipient has a plain wallet to make a share code. Contract wallets cannot derive document keys.
Sharing with one wallet
- The recipient makes a share code. On
/student, panel My share code, Show my share code asks for two signatures (the same message twice) and shows Your share code with Copy code and a fingerprint. The panel says: "Give this code to a person who wants to share a document with you. It is safe to send: it only lets them lock a document for your wallet. Read the fingerprint to them another way so they can check it is yours." - The holder seals the key. Under Share this document ("Ask the person for their share code (they make it on their Certichain page). The file you create can be opened only by the wallet that made that code. They keep what they read, and you cannot take it back."), the holder pastes the code into Recipient's share code. A valid code shows "Check this with the person:" and the fingerprint. Create shared file seals the document key to the code's public key with HPKE (no wallet needed) and saves
shared-document-<8 hex>.json: "Shared file saved. Send it to the person; only the wallet that made that share code can open it." - The recipient opens it. On
/student, panel Open a shared document ("Your wallet asks for two signatures so this page can work out your key; they do not send anything."), field Shared document file, Open document.DocumentService.openGrantchecks the file's digest, then asks the chain: the credential must exist and its anchored issuer and document hash must equal the file's, before the wallet is asked for anything. Then the wallet derives its key, which must match the grant'skid, and the container is fetched, checked against the issuer, decrypted and hashed.
The result card says "Decrypted in this browser. It matches the document hash recorded on the blockchain for this credential.", "Issued by 0x..., the address the blockchain records for this credential.", and links to Check that this credential is still active.
Sharing with a link
Create a link opens a confirmation, Create a link to this document?: "Anyone who gets the link can read the document. The link cannot expire or be limited to one person. To stop it, ask the issuer to replace the key; copies already saved are not affected." Create link shows Link to share with Copy link.
https://<site>/open#<credential id>.<cid>.<document key>
The /open page ("Shared with you", Open a shared document: "The key in this link is used only in this browser. The document is downloaded, decrypted here, and checked against the credential on the blockchain before you can save it.") takes the fragment once, clears it with history.replaceState, and keeps it in memory only. The route sends Referrer-Policy: no-referrer and Cache-Control: no-store. The link has no signature, so the chain decides whether the document belongs to a real credential.
| Problem | Message |
|---|---|
| No fragment (cut short, or reloaded after it was cleared) | "This page opens a document from a link that carries its key. The address has no key (the link was cut short, or the page was reloaded). Open the link again." |
| Malformed link | "This link is incomplete or damaged. Ask for a new one." |
| Credential not on this network | "No credential with this id was found on this network, so the document was not opened." |
| Chain or gateway unreachable | The message plus Try again |
Replacing a document key
An issuer replaces a document's key when a key leaked, a link must stop working, or a grant should no longer open future downloads. The credential does not change: same id, same document hash, same status.
- On the dashboard, a row's Replace key opens Replace this document's key?: "The document is encrypted again under a new key and stored as a new copy. You then get a key update file to send to the holder, and can remove the old copies." and "The old key stops opening the document once the old copies are removed. Someone who already saved the document or the old key keeps what they have." Button Replace key.
replaceDocumentKey(src/app/dashboard/replace-document-key.ts) first looks up the issuer's other credentials that anchor the same file (by document hash). If they cannot be read, nothing is replaced, because removal could not later tell their copies from this one's.DocumentService.rotateopens the newest copy the issuer can open, encrypts the same file again under a fresh document key, uploads it, and returns the new CID, a key update, and the list of older copies. Nothing is removed in this step, so a failure leaves the old copy working ("The key could not be replaced. Nothing was changed.").- The panel The new copy is stored under a new key. shows two steps:
- Hand the holder the key update: Save key update file saves
key-update-<8 hex>.json. "It is secret like the key itself. The holder applies it to their credential package; until they do, their old package opens nothing once the old copies are gone." - Remove the old copies: Remove old stored copies (N), enabled only after the key update file was saved ("Save the key update file first."). The result: "Removed N old copies from storage. A copy someone already saved elsewhere is not affected."
- Hand the holder the key update: Save key update file saves
- If other credentials of this issuer use the same file, removal is not offered: "N other credentials you issued use the same file. Their stored copies cannot be told apart from this one's, so the old copies are kept."
Removal on the server
DELETE /api/documents?documentHash=...&keep=<cid>&issuer=... requires the issuer session, refuses a query that names another wallet, and has its own budget (20 removals per wallet per hour). It lists the issuer's pins of that document hash, newest first, requires the kept CID to be among them (409 KEEP_NOT_FOUND: "The copy to keep is not stored under this account. Nothing was removed."), and unpins only the copies older than the kept one. It never removes the last copy and never touches another issuer's pins.
The holder applies the update
On /student, panel Open a document, after choosing the package: "Did the issuer send you a key update? Apply it first. It replaces the stored copy this package points at." The holder chooses Key update file. The app builds a new package with the new cid and documentKey, opens the document with it to prove it works, and only then saves it as credential-<8 hex>.json: "Package updated and saved. The new package opened the document. Keep the old file until you rely on the new one: the old one stops opening the document once the issuer removes the old copies." A key update for another credential gives "This key update belongs to a different credential than this package."
Wallet prompts
| Who | Action | Prompts |
|---|---|---|
| Recipient | Show my share code | 2 signatures (once per session) |
| Holder | Create shared file, Create a link | None |
| Recipient | Open document (grant) | 2 signatures (once per session) |
| Anyone | Opening a link | None |
| Issuer | Replace key | 2 signatures per key version tried (once per session), a sign-in message if needed |
| Issuer | Remove old stored copies | A sign-in message if the session expired |
| Holder | Applying a key update | None |
What sharing cannot do
- Take back what a recipient already opened or saved.
- Expire a link or limit it to one person.
- Tell the holder who opened a link.
- Work for documents stored before encryption (version 1 packages).
Code map
| Concern | File |
|---|---|
| Service | src/services/domain/document-service.ts: myShareCode, grantAccess, capabilityLink, openGrant, openCapability, rotate, removeOldCopies |
| Formats | src/lib/doc-crypto/share-code.ts, grant.ts, capability.ts, key-update.ts |
| Holder panels | src/app/student/SharePanel.tsx, MyShareCodePanel.tsx, SharedDocumentPanel.tsx, ApplyKeyUpdate.tsx, OpenDocumentPanel.tsx, OpenedDocumentNotice.tsx |
| Link page | src/app/open/OpenLinkContent.tsx, src/lib/security-headers.ts, next.config.ts |
| Issuer flow | src/app/dashboard/ReplaceKeyFlow.tsx, replace-document-key.ts |
| Server | src/app/api/documents/route.ts, src/lib/server/pinata-unpin.ts, src/services/document-api.ts |
Tests
src/services/domain/document-service.sharing.test.ts,document-service.rotation.test.tssrc/lib/doc-crypto/share-code.test.ts,grant.test.ts,capability.test.ts,key-update.test.ts,access-matrix.test.tssrc/app/student/SharePanel.test.tsx,MyShareCodePanel.test.tsx,SharedDocumentPanel.test.tsx,ApplyKeyUpdate.test.tsx,src/app/open/OpenLinkContent.test.tsxsrc/app/dashboard/ReplaceKeyFlow.test.tsx,replace-document-key.test.ts,src/app/api/documents/route.test.ts,src/lib/security-headers.test.ts- E2E:
e2e/journeys/share-and-replace.journey.ts,replace-shared-file.journey.ts