Status: accepted · Date: 2026-11-01 · Owner: t58-share-link
Enforcement:
packages/api/src/features/share-links/share-links.service.test.tspins 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.tsasserts US-32 (a fetch spy plus the realrpcTransportrecord 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.tskeeps the vault entry encrypted locally andlinkVaultexcluded fromSYNC_STORES.apps/mobile/src/features/share-links/share-links.clipboard.test.tsenforces 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 inshare-links.service.test.ts; bundle contents, secrets-before-request and reconciliation inshare-links.create.test.ts; the LocalStore seam inshare-links.vault.test.ts; the origin inpackages/env/src/native.test.ts.
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
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_ARGON2IDfrom ADR-0017: m = 12 MiB, t = 3, p = 1; a workerd-level semaphore caps 2 concurrent hashes).validity_daysandexpires_at— RN-09 capsvalidity_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
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
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
replacesLinkIdnaming the old link inshareLinks.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 theobject_deletionoutbox and appendsshare_link.revokedandshare_link.recreatedto 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.statusby link id names the report); the link screen takes the live link and its expiry fromshareLinks.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
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
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
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
<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
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
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
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
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
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.revokedandshare_link.recreated).
Consequences
shareLinks.createis 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:createis 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.resultvalues are already modelled inreport.enums.ts(ok,wrong_password,locked,expired,revoked).