A share link lets a nurse send a prescribing doctor a one-off, time-bounded
view of a report without a KIM address. The server only sees ciphertext and
an Argon2id hash of the password; the link key is composed of a 128-bit
fragment in the URL # and an 8-character password the nurse hands over
out of band. Decision:
ADR-0018;
code:
packages/api/src/features/share-links
and the public portal route
r.$linkId.
Key
link key = HKDF-SHA256(ikm = fragment (16 B, URL #k=) ‖ password (8 chars),
salt = linkId, info = "wundklar/share-link/v1")The nurse's device builds the encrypted bundle wk-share-v1
(AES-GCM(link key, { manifest, pdf, photoBefore, photoAfter })) and
uploads it to orgs/<org>/links/<linkId>.bin. The server stores the
ciphertext object and a PHC Argon2id hash of the password; it never persists
the password or the fragment. The fragment and the password live only in
the creating device's link vault (local, encrypted under the device's
local key, never synced) so a new password can only be set from that
device; otherwise the owner revokes the link and creates a new one.
Opening
- The doctor opens the URL
.../r/<linkId>#k=<fragment>in a clean profile. The browser reads the fragment from the hash — the hash is never sent to the server. - The password gate posts the password to
shareLinks.open. The server checks the PHC hash with Argon2id; a wrong password counts against the per-link counter. - On a match the server returns the signed GET URL of the ciphertext
bundle. The browser derives the link key from fragment + password,
decrypts in memory and renders
D-Doctorfrom the manifest.
Rate limits: 3 failures → 15 min lock, 5 failures → revoke and delete the
ciphertext (not just the index row). All opens, failures and revocations
are recorded in link_access with city, country, browser and the hashed IP.
Lifecycle
- Creation on the device (
M-RepLink) sets validity 1, 7 or 14 days fromLINK_VALIDITY_DAYSand records the recipient label (doctor, Wundnetz). - The server never signs a GET for a revoked, expired or locked link.
shareLinksExpireruns every tick, revokes expired links and deletes their R2 object; the counter stays for the access log.- The owner can revoke a link manually; the ciphertext is deleted synchronously and the trail records the reason.
What the server cannot do
- Decrypt an opened link's content: the fragment never reaches it.
- Replay a bundle at a different URL: the manifest binds
linkIdas AAD and the doctor's browser refuses mismatches. - Guess passwords offline: only the PHC hash is stored, and the ciphertext is deleted after five wrong tries.
Reports delivered over KIM or printed never go through this path; see reports.