Sharing files and codes
Four small formats move access to an encrypted document between people without any server holding a key. They are produced and read on the student page, the /open page and the issuer dashboard. The flow that uses them is described in Sharing documents.
| Format | Who makes it | Who reads it | Secret? |
|---|---|---|---|
| Share code | A recipient, from their wallet | The holder, to seal a key to that recipient | No. It is a public key. |
| Shared document file | The holder | The one recipient whose share code was used | Opens only for that wallet |
| Capability link | The holder | Anyone who gets the link | Yes. The key is in the link. |
| Key update file | The issuer, after replacing a document key | The holder, to update their package | Yes. It carries the new key. |
Share code
A share code is the recipient's X25519 public key (the same wallet-derived key described in Encrypted document container) with a version byte and a checksum, in one copyable string.
ccshare1_ + base64url( version (1 byte) ‖ public key (32 bytes) ‖ checksum (4 bytes) )
checksum = first 4 bytes of SHA-256( "certichain.share.v1" ‖ version ‖ public key )
fingerprint = kid of the public key (16 hex characters) in groups of four
| Part | Value |
|---|---|
| Prefix | ccshare1_ |
| Version | 1 |
| Body length | 37 bytes before base64url |
| Made on | /student, panel My share code, button Show my share code (two wallet signatures the first time in a session) |
| Shown with | The fingerprint, for example 0a1b 2c3d 4e5f 6071, to read out over another channel |
The checksum catches typing and copying mistakes. It does not prove whose key it is: the holder compares the fingerprint the share panel shows ("Check this with the person: ...") with the one the recipient reads out.
| Parse error | Message |
|---|---|
| Missing prefix | "This is not a share code." |
| Wrong length or character | "This share code is incomplete or contains a wrong character. Copy the whole code again." |
| Other version | "This share code was made by a newer version of the app." |
| Checksum mismatch | "This share code has a typing mistake. Copy the whole code again." |
Shared document file
A shared document file (a "grant") gives one recipient the key to one stored document. It carries the signed credential fields, so the recipient can see who issued it, and the document key sealed to the recipient's public key with HPKE. It carries no holder details and no key in the clear.
| Property | Value |
|---|---|
| File name | shared-document- followed by the first 8 hex characters of the credential id, .json |
| Made on | /student, panel Open a document, after a successful open: Share this document, field Recipient's share code, button Create shared file |
| Opened on | /student, panel Open a shared document, field Shared document file, button Open document |
| Size accepted | 64 KiB |
| Field | Type | Meaning | Validation |
|---|---|---|---|
type | string | "certichain-grant" | Exact value; another type is refused with the kind it looks like |
version | number | 1 | Exact value |
domain, credential, signature, credentialId | The signed credential fields, as in the credential package | readSignedFields | |
cid | string | IPFS CID of the container | Valid CID |
grant.kid | string | The recipient key id | 16 hex characters |
grant.enc | string | HPKE encapsulated key | Base64url, 32 bytes |
grant.ct | string | The sealed document key | Base64url, 48 bytes |
Any field outside this list, including a documentKey, is refused. The wrapping uses the same HPKE suite and the same info binding ("certichain.dek.v1" ‖ document hash) as the container's recipient entries.
Opening checks, in order (DocumentService.openGrant):
- The signed fields still hash to
credentialId; otherwise "This credential package is damaged, was changed after it was signed, or does not match the stored document, so it was not opened." - The chain is read: the credential must exist and its anchored issuer and document hash must equal the file's. Anyone can build a grant that agrees with itself, so the chain decides before the wallet is asked for anything.
- The wallet derives its key (two signatures) and must own
grant.kid; otherwise "Your account's keys do not open this document. It may have been stored with an earlier key or by another account." - The container is fetched, its header issuer must equal the credential's issuer, and the decrypted file must match the anchored hash.
The result shows "Issued by ..., the address the blockchain records for this credential." and a link Check that this credential is still active.
Capability link
A capability link carries the document key in the URL fragment. The fragment is never sent to a server, but anyone who has the link can read the document.
https://<site>/open#<credential id, lowercase>.<cid>.<base64url document key>
| Part | Rule |
|---|---|
| Origin | The site's own origin, http or https only |
| Credential id | 0x plus 64 hex characters, written in lowercase |
| CID | A valid CID |
| Key | Base64url of exactly 32 bytes |
Anything else gives "This link is incomplete or damaged. Ask for a new one."
The holder must confirm a dialog first (Create a link to this document?, button Create link): "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."
The /open page reads the fragment once, removes it from the address bar with history.replaceState, and keeps it only in memory. The route is served with Referrer-Policy: no-referrer and Cache-Control: no-store (OPEN_PAGE_HEADERS in src/lib/security-headers.ts). Because a link has no signature, the chain decides: the credential must exist and the container's header must name its issuer, and the decrypted file must match the anchored hash, before anything is shown.
Key update file
After an issuer replaces a document's key, the holder needs the new copy's address and the new key. The key update file carries exactly that. The signed credential fields do not change, so the credential id stays the same.
| Property | Value |
|---|---|
| File name | key-update- followed by the first 8 hex characters of the credential id, .json |
| Made on | Issuer dashboard, row action Replace key, then Save key update file |
| Applied on | /student, panel Open a document, "Did the issuer send you a key update?", field Key update file |
| Size accepted | 16 KiB |
| Field | Type | Meaning | Validation |
|---|---|---|---|
type | string | "certichain-key-update" | Exact value |
version | number | 1 | Exact value |
credentialId | string | The credential the update belongs to | 32-byte hex |
cid | string | CID of the new container | Valid CID |
documentKey | string | Base64url of the new 32-byte key | Exactly 32 bytes |
Extra fields are refused. Applying an update to a package of another credential fails with "This key update belongs to a different credential than this package." Applying it creates a new package (version 2, new cid and documentKey), opens the document with it to prove it works, and only then saves it as credential-<8 hex>.json. If the new package does not open, nothing is changed.
Examples
Fake values throughout.
ccshare1_AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
{
"type": "certichain-grant",
"version": 1,
"domain": { "name": "Certichain", "version": "1", "chainId": "11155111", "verifyingContract": "0x2222222222222222222222222222222222222222" },
"credential": {
"issuer": "0x1111111111111111111111111111111111111111",
"subjectCommitment": "0x0000000000000000000000000000000000000000000000000000000000000a01",
"documentHash": "0x9f2c4d0000000000000000000000000000000000000000000000000000000001",
"metadataHash": "0x0000000000000000000000000000000000000000000000000000000000000b02",
"recordType": 1,
"issuedAt": 1792800000,
"validUntil": 0,
"nonce": "7382917462837461"
},
"signature": "0x<65-byte signature>",
"credentialId": "0x0000000000000000000000000000000000000000000000000000000000000c03",
"cid": "bafybeiexampleexampleexampleexampleexampleexampleexampleexa",
"grant": {
"kid": "0a1b2c3d4e5f6071",
"enc": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"ct": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
}
{
"type": "certichain-key-update",
"version": 1,
"credentialId": "0x0000000000000000000000000000000000000000000000000000000000000c03",
"cid": "bafybeinewcopyexampleexampleexampleexampleexampleexampleexa",
"documentKey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
}
Source files
src/lib/doc-crypto/share-code.ts:encodeShareCode,parseShareCode,shareFingerprintsrc/lib/doc-crypto/grant.ts:buildGrant,parseGrant,unwrapGrantsrc/lib/doc-crypto/capability.ts:buildCapabilityFragment,parseCapabilityFragmentsrc/lib/doc-crypto/key-update.ts:buildKeyUpdate,parseKeyUpdate,applyKeyUpdatesrc/lib/doc-crypto/file-kind.ts:typevalues and wrong-file detectionsrc/lib/doc-crypto/errors.ts:ShareCodeError,CapabilityLinkError,KeyUpdateMismatchError,WrongFileKindErrorsrc/services/domain/document-service.ts:myShareCode,grantAccess,capabilityLink,openGrant,openCapability,rotatesrc/lib/security-headers.ts: headers for/open
Tests
src/lib/doc-crypto/share-code.test.ts,grant.test.ts,capability.test.ts,key-update.test.tssrc/services/domain/document-service.sharing.test.ts,src/services/domain/document-service.rotation.test.tssrc/app/student/SharePanel.test.tsx,SharedDocumentPanel.test.tsx,MyShareCodePanel.test.tsx,ApplyKeyUpdate.test.tsx,src/app/open/OpenLinkContent.test.tsx- E2E:
e2e/journeys/share-and-replace.journey.ts,e2e/journeys/replace-shared-file.journey.ts