Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Zero-knowledge share link

The repository's architectural decision, rationale and consequences.

Markdown

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

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_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

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 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.

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.revoked and share_link.recreated).

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).
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.