# Zero-knowledge share link

The repository's architectural decision, rationale and consequences.

# Zero-knowledge share link

**Status:** accepted · **Date:** 2026-11-01 · **Owner:** t58-share-link

**Enforcement:**

- `packages/api/src/features/share-links/share-links.service.test.ts` pins the
  storage model (ciphertext ref + sha256 + PHC hash + validity), RN-09, C6
  (password never written in clear or logged) and the clinical boundary.
- `apps/mobile/src/features/share-links/share-links.create.test.ts` asserts
  US-32 (a fetch spy plus the real `rpcTransport` record every outbound
  request; none carries the fragment or the derived key) and bundle
  round-trip integrity.
- `apps/mobile/src/features/share-links/share-links.vault.test.ts` keeps the
  vault entry encrypted locally and `linkVault` excluded from `SYNC_STORES`.
- `apps/mobile/src/features/share-links/share-links.clipboard.test.ts` enforces
  the 60 s clipboard wipe (and the "do not overwrite the user's new copy"
  guard), and that a refused copy keeps the previous wipe.
- Ticket 58 review fixes ("58: …" tests): `objects.offline-stage.test.ts`
  (staging against the existing report, ADR-0025 amendment), idempotent
  create, concurrent rate limit and expired/revoked download refusal in
  `share-links.service.test.ts`; bundle contents, secrets-before-request and
  reconciliation in `share-links.create.test.ts`; the LocalStore seam in
  `share-links.vault.test.ts`; the origin in `packages/env/src/native.test.ts`.

## Decision [#decision]

A Wundklar share link separates custody of the ciphertext from custody of the
key material.

```
device                               wire                               server
───────────────────────────────────  ────────────────────────────────   ──────────────
newFragmentKey (16 B, base64url 22)                                     (never seen)
newPassword    (8 Crockford base32)                                     (TLS-only; Argon2id-hashed in-process, discarded)
deriveLinkKey  = HKDF-SHA256(                                           (cannot derive;
                 fragment ‖ password,                                    no fragment, no plaintext)
                 salt = linkId,
                 info = "wundklar/share-link/v1")
sealBundle (AES-256-GCM, AAD
            "wundklar/share-link/
             bundle/v1")
link vault.put(pending: fragment,
  password, ciphertext, object ref)    (nothing sent yet)
objects.stage {…, parent: {reportId}}  PUT ciphertext bytes             R2 object (ciphertext only)
shareLinks.create { password,          (TLS)                            share_link row:
                   object: { key,                                        - ciphertext_object_key
                             sha256,                                     - ciphertext_sha256
                             byteSize }}                                 - password_hash (Argon2id PHC)
                                                                        - validity_days ∈ {1,7,14}
                                                                        - expires_at = createdAt + days
                                                                        attachStagedObjects (atomic)
                                                                        trail: share_link.created
                                                                        audit outbox: link.created
                                                                        rate limit: 20 creates/user/hour
                                                                        (count + insert under a per-user
                                                                         advisory lock, one transaction)
link vault.put(active: expiresAt from
  the server; pending part dropped)
                                       url carries `#k=<fragment>`      (browsers never send the fragment)
```

### What the server stores [#what-the-server-stores]

The `share_link` row (ADR-10, ticket 10) holds only:

- `ciphertext_object_key` — points at the R2 object.
- `ciphertext_sha256` — witnesses the exact uploaded bytes.
- `password_hash` — an Argon2id PHC string (`DEFAULT_ARGON2ID` from ADR-0017:
  m = 12 MiB, t = 3, p = 1; a workerd-level semaphore caps 2 concurrent hashes).
- `validity_days` and `expires_at` — RN-09 caps `validity_days ∈ {1, 7, 14}`.

Nothing in that row recovers the fragment or the plaintext. The password is
seen only inside the create procedure's in-process scope, hashed, then its
reference is dropped; no logger receives it.

### Fragment and password custody [#fragment-and-password-custody]

The fragment key (16 random bytes, URL-encoded `#k=`) and the 8-character
Crockford base32 password live only in the creating device's `linkVault`
store, encrypted under the vault's local key. The vault opens through the
platform `openLocalStore` (`platform/local-store/local-store-open.ts`), so
ticket 75's native store replaces IndexedDB without touching this feature. `SYNC_STORES` deliberately
excludes `linkVault`: the local store module and the share-links feature are
the only writers.

### Rotation [#rotation]

There is no in-place password change. The ciphertext is bound to the
fragment+password pair, and overwriting it under the same object key cannot
be made atomic with the hash swap: an opener between the two steps gets bytes
the password no longer opens, an interrupted rotation leaves the link
undecryptable, and tokens minted under the old password stay valid (ticket 60
review, findings 1–3). "New password" is therefore **re-create**:

- The device makes a new link through the create flow above: new link id, new
  fragment, new password, new ciphertext object, and `replacesLinkId` naming
  the old link in `shareLinks.create`.
- In the same server transaction the server checks that the caller created the
  old link, revokes it (`revoke_reason = "recreated"`), queues its ciphertext
  in the `object_deletion` outbox and appends `share_link.revoked` and
  `share_link.recreated` to the trail. A revoked link fails every token path
  (open, blob, reply), so old tokens and the old password stop working at
  commit; an open that verified the old password before the commit re-reads
  the row under the link lock and is refused.
- Re-create is the creator's only. Any device of the creator can do it: it
  rebuilds the bundle from the report, so it does not need the old link's
  vault entry. A replacement phone still sees status and the access log and
  can revoke (ticket 60).
- The access screen reads only the server (`shareLinks.status` by link id names
  the report); the link screen takes the live link and its expiry from
  `shareLinks.list`. The vault entry supplies only the URL and password, so a
  device without it can manage the link but not display it. Revoke and
  re-create keep the old entry but clear its URL, fragment and password. Every
  management action reloads status, log, list and vault; a failed read shows
  an error, never a stale "active" or "No access yet".

Revocation (`shareLinks.revoke`, re-create, the expiry job) sets `revoked_at`
and inserts the `object_deletion` row in one transaction. Tenants may insert
only a fresh, receipt-less outbox row whose key is the ciphertext of a revoked
link of their own organization (`object_deletion_tenant_insert`). The expiry
job also queues any revoked link whose ciphertext has no outbox row.

### Who manages a link [#who-manages-a-link]

Extend, revoke, status and the access log are allowed for the link's creator
and for the PDL and admins (`team:manage` in `team.rules`); other members are
refused. Re-create is the creator's only. "+7 days" adds seven days to the
current expiry, capped at creation + 14 days (RN-09), and appends
`share_link.extended` (old and new expiry) in the same transaction.

### Access log [#access-log]

Every portal open writes one `link_access` row: time, result, the browser
family from the user agent, and the city from Cloudflare's `request.cf.city`
(set by the edge, never by the client; null off Workers). The raw IP is never
stored: only its keyed hash, used for the attempt counter. The sender reads
the log newest first, 50 rows a page, with a keyset cursor (`at`, `id`) from
the previous page's `nextCursor`. The expiry job deletes a link's log rows 90
days after the link expired or was revoked, whichever came first (RF-22).

### Expiry [#expiry]

`shareLinksExpire` revokes expired links (`revoke_reason = "expired"`) and
queues their ciphertext for deletion in every organization. Deleting the
ciphertext of an expired link is a privacy obligation (RN-09), not a clinical
write, so it is not held back by `clinicalJobAccess`: for an organization that
is read-only, export-only, closed or without an AVV, the job marks the link
expired and queues the deletion and writes nothing else (no trail, no audit).
For every other organization it also appends `share_link.revoked` and the
audit entry. Everything else the job does stays behind `clinicalJobAccess`.

### URL shape [#url-shape]

```
<web origin>/r/<linkId>#k=<fragment, raw base64url>
```

The path carries the full linkId: the portal needs it to find the row and as
the HKDF salt. The prototype's six-character short code and dashed key
grouping are not used: a prefix has no unique column behind it, and dashes are
part of the base64url alphabet, so a grouped key would not parse back. The
origin is `SHARE_LINK_ORIGIN` (plan §716), delivered to the app as
`EXPO_PUBLIC_SHARE_LINK_ORIGIN` through `packages/env/src/native.ts` (see
`docs/wundklar/HARNESS.md`); never `EXPO_PUBLIC_SERVER_URL`, which may be an
internal or device-specific API address.

### Bundle contents [#bundle-contents]

The bundle holds exactly the selected report's parts: `pdf` (the decrypted
report PDF), `portal_manifest` (ticket 54's `portalManifestFromModel`, always
pseudonymised, built from the report's records as of its creation) and
`crop_before`/`crop_after` (the wound crops of the report's first and last
photo up to its creation). A full frame is never included. A crop this device
cannot reach is left out; the PDF still carries the report.

### Secrets before the request, idempotent create [#secrets-before-the-request-idempotent-create]

The device writes the fragment, password, exact ciphertext and object
reference to the link vault as a pending operation before any request. A
retry for the same report replays that operation: the same stage intent, the
same ciphertext (a repeated PUT returns 412) and the same link id.
`shareLinks.create` is idempotent on the link id: the same user, device,
report, object and validity get the existing link back; anything else under
that id is `CONFLICT`. If create fails ambiguously (no response), the device
asks `shareLinks.list` whether the link exists before it shows success or a
failure; it drops the pending operation only on a 4xx refusal while the
server confirms no such link.

### Validity enforced on the server [#validity-enforced-on-the-server]

`objects.downloadUrl` serves a `share_bundle` only while its link is neither
expired nor revoked. The public open path (ticket 59) and revoke (ticket 60)
must refuse expired or revoked links independently.

### Clipboard hygiene [#clipboard-hygiene]

`copyPasswordWithExpiry` awaits the clipboard write, then after 60 s reads the
clipboard; if it still holds the password it wipes it. If the user has copied
something else in the meantime, the helper leaves it alone (never destroys
unrelated content a user just put on the clipboard). Copying the link runs the
same wipe, because the URL carries the fragment. "Copied" appears only after
the write succeeded; a refused write shows a failure and keeps the previous
copy's wipe scheduled (`createClipboardSlot`). Leaving the screen does not
cancel a pending wipe.

## Why this split [#why-this-split]

The hidden control is "which identities see which secrets". The fragment is
tied to the URL, the password to a side channel, and the hash to a bounded
guess budget. Dropping any one of them collapses the budget to a server-side
offline attack (if the hash leaks) or a link-sharing accident (if the password
sits next to it). The split keeps each surface answerable to one failure mode:

- The URL may leak publicly (chat screenshots, referrer, access log): without
  the password, the ciphertext is unreadable; the 5-try + Argon2id budget
  caps the opener's chances.
- The database may be dumped: without the fragment, the opener cannot derive
  the AES key regardless of guesses.
- The device may be lost: no server-side rotation is possible by design, so
  revoke-and-re-create is the only path and audit tells the owner so (ADR-0021
  trail events `share_link.revoked` and `share_link.recreated`).

## Consequences [#consequences]

- `shareLinks.create` is a direct oRPC (not a sync mutation): Argon2id hashing
  of a plaintext password requires a live, authenticated call with TLS. The
  procedure runs through the clinical boundary; `link:create` is the required
  permission.
- The server never sees the fragment, so an export never includes it (the row
  only carries ciphertext key + hash); the KIM/MD exports can only quote the
  URL as the creating device wrote it.
- Ticket 60 implements the open flow; its budget and `link_access.result`
  values are already modelled in `report.enums.ts` (`ok`, `wrong_password`,
  `locked`, `expired`, `revoked`).

