# Wundklar · share links

The zero-knowledge path from the nurse's device to the doctor's browser: the server stores ciphertext and a password hash, never the key.

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](/docs/adr/0018-zero-knowledge-share-link);
code:
[`packages/api/src/features/share-links`](https://github.com/CarlosZiegler/wundklare/blob/main/packages/api/src/features/share-links/share-links.service.ts)
and the public portal route
[`r.$linkId`](https://github.com/CarlosZiegler/wundklare/blob/main/apps/web/src/routes/r.$linkId.tsx).

## Key [#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 [#opening]

1. 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.
2. 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.
3. 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-Doctor` from 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 [#lifecycle]

- Creation on the device (`M-RepLink`) sets validity 1, 7 or 14 days from
  `LINK_VALIDITY_DAYS` and records the recipient label (doctor, Wundnetz).
- The server never signs a GET for a revoked, expired or locked link.
- `shareLinksExpire` runs 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 [#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 `linkId` as 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](/docs/features/wundklar-reports).

