From 53d41c190b94c82bb5201e6862ae55f682bdb4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:25 +0200 Subject: [PATCH 1/5] Add nullable passphrase hash to clinician share links Introduce a nullable passphrase_hash column on the share-link model so a link can carry a passphrase second factor. The column is nullable, so every link created before this change keeps the possession-only contract and the public view renders for it without a gate. Migration 0181 hand-authors the ALTER TABLE; the column stores only the HMAC hash, never the raw passphrase. --- .../0181_v1187_share_link_passphrase/migration.sql | 7 +++++++ prisma/schema.prisma | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 prisma/migrations/0181_v1187_share_link_passphrase/migration.sql diff --git a/prisma/migrations/0181_v1187_share_link_passphrase/migration.sql b/prisma/migrations/0181_v1187_share_link_passphrase/migration.sql new file mode 100644 index 000000000..1ba75e46a --- /dev/null +++ b/prisma/migrations/0181_v1187_share_link_passphrase/migration.sql @@ -0,0 +1,7 @@ +-- v1.18.7 — passphrase second factor for clinician share links. +-- A leaked share URL on its own no longer opens the record: every new +-- link carries a high-entropy passphrase, stored only as its HMAC-SHA256 +-- hash (same key as token_hash). The column is NULLABLE so every link +-- minted before this change keeps the possession-only contract and the +-- public view renders for it without a gate. +ALTER TABLE "clinician_share_links" ADD COLUMN "passphrase_hash" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6d3092f4..7cac927df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3856,6 +3856,13 @@ model ClinicianShareLink { userId String @map("user_id") /// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`). tokenHash String @unique @map("token_hash") + /// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as + /// `tokenHash`). NULL for legacy links minted before the passphrase gate; + /// those keep the possession-only contract (the public view renders without + /// a gate). A non-null value forces the unlock gate before the record paints. + /// The raw passphrase is returned exactly once at create time and stored only + /// as this hash — there is no recovery path. + passphraseHash String? @map("passphrase_hash") /// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded. label String From 1aeb6f79578a9415253bab79686aaa9edc020848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:36 +0200 Subject: [PATCH 2/5] Mint a passphrase second factor on share-link create Every new share link now carries a high-entropy passphrase (80-bit, Crockford-base32, grouped XXXX-XXXX-XXXX-XXXX). The create response returns the raw passphrase, the share URL, and a QR deep link EXACTLY ONCE alongside the token; the server stores only the passphrase's HMAC hash (the same key as the token hash). The QR deep link carries the passphrase in the URL fragment (#k=) so it never reaches a server log, referrer, or access record. Add the passphrase generator, normaliser, and constant-time verifier; the short-lived token-scoped unlock-cookie mint/verify; a gate-state resolver that reports live + passphrase status without bumping access counters; and the unlock request schema. --- .../api/share-links/__tests__/route.test.ts | 39 +++++++ src/app/api/share-links/route.ts | 56 ++++++++- src/lib/clinician-share/passphrase.ts | 108 ++++++++++++++++++ .../clinician-share/resolve-share-token.ts | 33 +++++- src/lib/clinician-share/unlock-cookie.ts | 81 +++++++++++++ src/lib/validations/clinician-share-link.ts | 13 +++ 6 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 src/lib/clinician-share/passphrase.ts create mode 100644 src/lib/clinician-share/unlock-cookie.ts diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index 78f4f4271..ae94c8fce 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -75,6 +75,7 @@ function storedRow(overrides: Record = {}) { rangeEnd: null, resourceTypes: ["Observation"], allowFhirApi: true, + passphraseHash: "hash(STORED)", expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), createdAt: new Date(), revokedAt: null, @@ -119,6 +120,44 @@ describe("POST /api/share-links — create", () => { expect(createArg.data.userId).toBe("user-1"); }); + it("returns the raw passphrase once and persists ONLY its hash", async () => { + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow({ passphraseHash: "hash(STORED)" }) as never, + ); + + const res = await POST(postReq(validBody())); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { + passphrase: string; + token: string; + shareUrl: string; + qrUrl: string; + protected: boolean; + }; + }; + + // The raw passphrase is returned exactly once in the grouped form. + expect(body.data.passphrase).toMatch( + /^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/, + ); + + // Stored as a hash (the mock wraps the NORMALISED bare form), never raw. + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + const bare = body.data.passphrase.replace(/-/g, ""); + expect(createArg.data.passphraseHash).toBe(`hash(${bare})`); + expect(createArg.data.passphraseHash).not.toContain(body.data.passphrase); + + // The QR payload carries the passphrase ONLY in the URL fragment (`#k=`), + // never the path or query — and the bare share URL never carries it. + expect(body.data.qrUrl).toContain(`#k=${body.data.passphrase}`); + expect(body.data.shareUrl).not.toContain(body.data.passphrase); + expect(body.data.shareUrl).toContain(`/c/${body.data.token}`); + // Summary reports the link as passphrase-protected. + expect(body.data.protected).toBe(true); + }); + it("rejects an expiry beyond the cap (422)", async () => { const tooFar = new Date( Date.now() + 200 * 24 * 60 * 60 * 1000, diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 4b0839681..8cfa15d44 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -29,8 +29,31 @@ import { import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { + generatePassphrase, + hashPassphrase, + normalisePassphrase, + PASSPHRASE_FRAGMENT_KEY, +} from "@/lib/clinician-share/passphrase"; import type { Prisma } from "@/generated/prisma/client"; +/** + * The absolute origin to build the public share URL from. Prefers the + * forwarded host/proto a reverse proxy sets (the documented self-hosting path + * fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's + * own origin. The QR payload carries the passphrase in the FRAGMENT only. + */ +function resolveOrigin(request: NextRequest): string { + const proto = + request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || + new URL(request.url).protocol.replace(":", ""); + const host = + request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() || + request.headers.get("host") || + new URL(request.url).host; + return `${proto}://${host}`; +} + /** Project a stored row to the safe owner-facing shape (never the token). */ function toSummary(row: { id: string; @@ -39,6 +62,7 @@ function toSummary(row: { rangeEnd: Date | null; resourceTypes: string[]; allowFhirApi: boolean; + passphraseHash: string | null; expiresAt: Date; createdAt: Date; revokedAt: Date | null; @@ -52,6 +76,9 @@ function toSummary(row: { rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null, resourceTypes: row.resourceTypes, allowFhirApi: row.allowFhirApi, + // v1.18.7 — surface only WHETHER a passphrase guards the link, never the + // hash itself. Legacy (null-hash) links read `false` and stay ungated. + protected: row.passphraseHash !== null, expiresAt: row.expiresAt.toISOString(), createdAt: row.createdAt.toISOString(), revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null, @@ -91,12 +118,20 @@ export const POST = apiHandler(async (request: NextRequest) => { const rawToken = `hls_${randomBytes(24).toString("hex")}`; const tokenHash = hashToken(rawToken); + // v1.18.7 — always mint a passphrase second factor; store only its HMAC + // hash. The raw passphrase is returned exactly once below. `normalisePassphrase` + // is the canonical store form so the grouped (`XXXX-XXXX-…`) display and the + // bare fragment value collide to one hash. + const rawPassphrase = generatePassphrase(); + const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!); + // Field-by-field build — no mass assignment. Scope columns are written // exactly once here and never updated. const created = await prisma.clinicianShareLink.create({ data: { userId: user.id, tokenHash, + passphraseHash, label: input.label, rangeStart: new Date(input.rangeStart), rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null, @@ -112,6 +147,7 @@ export const POST = apiHandler(async (request: NextRequest) => { rangeEnd: true, resourceTypes: true, allowFhirApi: true, + passphraseHash: true, expiresAt: true, createdAt: true, revokedAt: true, @@ -131,8 +167,23 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // The raw token is returned exactly once; it is unrecoverable thereafter. - return apiSuccess({ ...toSummary(created), token: rawToken }, 201); + // The raw token AND raw passphrase are returned exactly once; both are + // unrecoverable thereafter. The QR/deep-link payload carries the passphrase + // in the URL FRAGMENT (`#k=`) — never the path or query — so it is never + // sent to the server, kept out of logs, referrers, and the access record. + const origin = resolveOrigin(request); + const shareUrl = `${origin}/c/${rawToken}`; + const qrUrl = `${shareUrl}#${PASSPHRASE_FRAGMENT_KEY}=${rawPassphrase}`; + return apiSuccess( + { + ...toSummary(created), + token: rawToken, + passphrase: rawPassphrase, + shareUrl, + qrUrl, + }, + 201, + ); }); export const GET = apiHandler(async () => { @@ -149,6 +200,7 @@ export const GET = apiHandler(async () => { rangeEnd: true, resourceTypes: true, allowFhirApi: true, + passphraseHash: true, expiresAt: true, createdAt: true, revokedAt: true, diff --git a/src/lib/clinician-share/passphrase.ts b/src/lib/clinician-share/passphrase.ts new file mode 100644 index 000000000..556ac53e0 --- /dev/null +++ b/src/lib/clinician-share/passphrase.ts @@ -0,0 +1,108 @@ +/** + * v1.18.7 — clinician share-link passphrase second factor. + * + * A share URL is now possession AND knowledge: a leaked `/c/` link + * without the passphrase cannot open the record. Every new link gets a + * high-entropy passphrase minted here, returned to the owner EXACTLY ONCE, + * and stored only as its HMAC-SHA256 hash (the same `hashToken` scheme as the + * token itself, keyed by `API_TOKEN_HMAC_KEY`). There is no recovery path. + * + * Shape: 16 Crockford-base32 characters grouped `XXXX-XXXX-XXXX-XXXX`. The raw + * 16 chars carry 80 bits of entropy; we draw them from 10 random bytes (80 + * bits) so the human-typeable form is the full secret with no truncation. The + * QR payload (built by the create route) carries this in the URL FRAGMENT so it + * never reaches a server log, referrer, or access record. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +import { hashToken } from "@/lib/auth/hmac"; + +/** + * Crockford base32 alphabet (no I/L/O/U — removes the characters most often + * misread when typed by hand). 32 symbols → 5 bits each. + */ +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** Group size for the human-typeable form (`XXXX-XXXX-XXXX-XXXX`). */ +const GROUP = 4; +/** Total characters in a raw passphrase (16 × 5 bits = 80 bits). */ +const LENGTH = 16; + +/** The fragment key the QR / deep-link uses: `#k=`. */ +export const PASSPHRASE_FRAGMENT_KEY = "k"; + +/** + * The canonical regex a submitted passphrase must match once normalised + * (uppercased, separators stripped): 16 Crockford-base32 characters. + */ +export const PASSPHRASE_PATTERN = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`); + +/** + * Generate a fresh passphrase in the grouped human-typeable form + * (`XXXX-XXXX-XXXX-XXXX`). Drawn from CSPRNG bytes via rejection-free + * masking — each character maps a 5-bit slice of the random stream onto the + * 32-symbol alphabet, so the distribution is uniform. + */ +export function generatePassphrase(): string { + // 16 chars × 5 bits = 80 bits → 10 bytes of entropy. + const bytes = randomBytes(10); + let bits = 0; + let value = 0; + const chars: string[] = []; + for (let i = 0; i < bytes.length && chars.length < LENGTH; i++) { + value = (value << 8) | bytes[i]; + bits += 8; + while (bits >= 5 && chars.length < LENGTH) { + bits -= 5; + chars.push(ALPHABET[(value >>> bits) & 0x1f]); + } + } + const groups: string[] = []; + for (let i = 0; i < chars.length; i += GROUP) { + groups.push(chars.slice(i, i + GROUP).join("")); + } + return groups.join("-"); +} + +/** + * Normalise a passphrase a human (or a QR fragment) submitted: strip every + * separator / whitespace and uppercase. Lowercase `l`→`L` style confusions are + * left to the alphabet (which already excludes I/L/O/U), so a value that does + * not normalise to the canonical pattern is simply rejected by the caller. + * Returns the bare 16-char form, or `null` when it does not match the pattern. + */ +export function normalisePassphrase(raw: string): string | null { + const stripped = raw.replace(/[\s-]+/g, "").toUpperCase(); + return PASSPHRASE_PATTERN.test(stripped) ? stripped : null; +} + +/** + * Hash a passphrase for storage / comparison. Normalises first so the grouped + * and bare forms collide to one hash; throws (via `normalisePassphrase` → + * caller) only when the input is malformed. Returns the lowercase hex HMAC. + */ +export function hashPassphrase(normalised: string): string { + return hashToken(normalised); +} + +/** + * Constant-time check of a submitted passphrase against a stored hash. Returns + * false for a malformed submission, a null stored hash, or any mismatch — and + * never short-circuits on length so timing leaks nothing about the secret. + */ +export function verifyPassphrase( + submitted: string, + storedHash: string | null, +): boolean { + if (!storedHash) return false; + const normalised = normalisePassphrase(submitted); + if (!normalised) return false; + const candidate = Buffer.from(hashPassphrase(normalised), "hex"); + const expected = Buffer.from(storedHash, "hex"); + if (candidate.length !== expected.length) return false; + try { + return timingSafeEqual(candidate, expected); + } catch { + return false; + } +} diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts index dba0a02dd..7eb3c8494 100644 --- a/src/lib/clinician-share/resolve-share-token.ts +++ b/src/lib/clinician-share/resolve-share-token.ts @@ -27,7 +27,7 @@ import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; /** The `hls_<48 hex>` shape the lifecycle route mints (192-bit body). */ -const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/; +export const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/; /** * The scoped read context a resolved share token yields. It is intentionally a @@ -117,3 +117,34 @@ export async function resolveShareToken( expiresAt: row.expiresAt, }; } + +/** + * v1.18.7 — the live-gate state for a raw token, WITHOUT bumping access + * counters. Used by the public page (decide gate vs render) and the unlock + * route (verify a passphrase). It deliberately resolves a malformed / unknown / + * revoked / expired token to `null` — the same blunt nothing the view returns — + * so the unlock surface leaks no more than the page does. It carries the stored + * `passphraseHash` and the `tokenHash` (for cookie scoping + rate-limit keying) + * but never the owner scope; the full read still flows through + * {@link resolveShareToken}. + */ +export interface ShareGateState { + tokenHash: string; + /** `null` for a legacy link minted before the passphrase gate. */ + passphraseHash: string | null; +} + +export async function resolveShareGateState( + rawToken: string | null | undefined, +): Promise { + if (!rawToken || !SHARE_TOKEN_PATTERN.test(rawToken)) return null; + const tokenHash = hashToken(rawToken); + const row = await prisma.clinicianShareLink.findUnique({ + where: { tokenHash }, + select: { passphraseHash: true, revokedAt: true, expiresAt: true }, + }); + if (!row) return null; + if (row.revokedAt !== null) return null; + if (row.expiresAt.getTime() <= Date.now()) return null; + return { tokenHash, passphraseHash: row.passphraseHash }; +} diff --git a/src/lib/clinician-share/unlock-cookie.ts b/src/lib/clinician-share/unlock-cookie.ts new file mode 100644 index 000000000..dbb6cb366 --- /dev/null +++ b/src/lib/clinician-share/unlock-cookie.ts @@ -0,0 +1,81 @@ +/** + * v1.18.7 — short-lived unlock cookie for a passphrase-gated share link. + * + * After the public unlock route verifies the passphrase it mints a signed, + * httpOnly, SameSite=Strict, Secure (per `shouldEmitSecureCookie`) cookie + * SCOPED to that one token's view path (`/c/`). The page checks the + * cookie server-side before rendering the record; presenting an unlock for one + * token never unlocks another (the cookie name + path both pin the token hash). + * + * The value is opaque and self-verifying: `.` where the HMAC is + * over `"."` keyed by `API_TOKEN_HMAC_KEY`. No server-side + * session row is needed; a forged or replayed-across-token value fails the + * constant-time HMAC check, and an expired one fails the timestamp check. TTL + * is short (30 min) so a shared device window closes quickly. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** Unlock-cookie lifetime: 30 minutes. */ +export const UNLOCK_TTL_SECONDS = 30 * 60; + +const MIN_HMAC_KEY_LENGTH = 32; + +function unlockKey(): string { + const key = process.env.API_TOKEN_HMAC_KEY; + if (!key) { + throw new Error("API_TOKEN_HMAC_KEY env var must be set"); + } + if (key.length < MIN_HMAC_KEY_LENGTH) { + throw new Error( + `API_TOKEN_HMAC_KEY must be at least ${MIN_HMAC_KEY_LENGTH} characters, got ${key.length}.`, + ); + } + return key; +} + +/** + * The per-token cookie name. A short prefix of the token hash keeps cookies for + * different shares distinct without putting the full hash in the header. The + * path scoping (`/c/`) is the real isolation boundary; the name suffix + * only avoids cross-token collisions inside the same browser. + */ +export function unlockCookieName(tokenHash: string): string { + return `hls_unlock_${tokenHash.slice(0, 16)}`; +} + +function sign(tokenHash: string, expiryMs: number): string { + return createHmac("sha256", unlockKey()) + .update(`${tokenHash}.${expiryMs}`) + .digest("hex"); +} + +/** Mint a fresh unlock-cookie value bound to this token hash. */ +export function mintUnlockValue(tokenHash: string): string { + const expiryMs = Date.now() + UNLOCK_TTL_SECONDS * 1000; + return `${expiryMs}.${sign(tokenHash, expiryMs)}`; +} + +/** + * Verify an unlock-cookie value against the token hash. Constant-time on the + * HMAC; returns false for any malformed, forged, cross-token, or expired value. + */ +export function verifyUnlockValue( + value: string | undefined, + tokenHash: string, +): boolean { + if (!value) return false; + const dot = value.indexOf("."); + if (dot <= 0) return false; + const expiryMs = Number(value.slice(0, dot)); + if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()) return false; + const received = value.slice(dot + 1); + const expected = sign(tokenHash, expiryMs); + const a = Buffer.from(received, "hex"); + const b = Buffer.from(expected, "hex"); + if (a.length !== b.length || a.length === 0) return false; + try { + return timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index 34c58fc00..01b9b8467 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -64,3 +64,16 @@ export const createShareLinkSchema = z ); export type CreateShareLinkInput = z.infer; + +/** + * v1.18.7 — public unlock payload. A clinician submits the passphrase second + * factor (grouped or bare form). Bounded so a junk body cannot pin the hasher; + * the route normalises + constant-time compares against the stored hash. + */ +export const unlockShareLinkSchema = z + .object({ + passphrase: z.string().trim().min(1).max(64), + }) + .strict(); + +export type UnlockShareLinkInput = z.infer; From 5783acacbb57825391189637a8bca1437ac5c00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:46 +0200 Subject: [PATCH 3/5] Gate the public share view behind the passphrase A protected share link no longer renders the record until the passphrase is verified. The public page checks the gate state first (without bumping access counters), and for a link with a passphrase hash it serves a gate island unless a valid, token-scoped unlock cookie is present. A legacy link with a null passphrase hash renders exactly as before. The unlock route verifies the submitted passphrase with a constant-time compare against the stored hash, is rate-limited by share-unlock token and IP before any compare, and on success sets a short-lived (30 min) httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view path. Every failure class answers one blunt 401; no-store and the flat 404 for unknown links are preserved. The gate island auto-submits the #k= fragment (scrubbing it from history first) or prompts for input. --- .../c/[token]/unlock/__tests__/route.test.ts | 150 ++++++++++++++++++ src/app/api/c/[token]/unlock/route.ts | 106 +++++++++++++ src/app/c/[token]/__tests__/page.test.tsx | 116 +++++++++++--- src/app/c/[token]/page.tsx | 36 ++++- .../clinician/share-unlock-gate.tsx | 130 +++++++++++++++ .../__tests__/passphrase.test.ts | 87 ++++++++++ .../resolve-share-gate-state.test.ts | 86 ++++++++++ .../__tests__/unlock-cookie.test.ts | 62 ++++++++ src/proxy.ts | 6 + 9 files changed, 755 insertions(+), 24 deletions(-) create mode 100644 src/app/api/c/[token]/unlock/__tests__/route.test.ts create mode 100644 src/app/api/c/[token]/unlock/route.ts create mode 100644 src/components/clinician/share-unlock-gate.tsx create mode 100644 src/lib/clinician-share/__tests__/passphrase.test.ts create mode 100644 src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts create mode 100644 src/lib/clinician-share/__tests__/unlock-cookie.test.ts diff --git a/src/app/api/c/[token]/unlock/__tests__/route.test.ts b/src/app/api/c/[token]/unlock/__tests__/route.test.ts new file mode 100644 index 000000000..7c0828da3 --- /dev/null +++ b/src/app/api/c/[token]/unlock/__tests__/route.test.ts @@ -0,0 +1,150 @@ +/** + * v1.18.7 — public passphrase-gate verifier (POST /api/c/{token}/unlock). + * + * Asserts the load-bearing properties: + * - a correct passphrase sets a short-lived, token-scoped httpOnly cookie; + * - a wrong passphrase is rejected with a blunt 401, no cookie set; + * - an unknown / revoked / expired token (gate null) is the same blunt 401; + * - a link with no passphrase set cannot be unlocked; + * - the route is rate-limited BEFORE any compare. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +const cookieSet = vi.fn(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: cookieSet, + delete: () => {}, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: {}, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ + resolveShareGateState: vi.fn(), +})); +vi.mock("@/lib/clinician-share/passphrase", () => ({ + verifyPassphrase: vi.fn(), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { POST } from "../route"; +import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token"; +import { verifyPassphrase } from "@/lib/clinician-share/passphrase"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; + +const TOKEN = `hls_${"a".repeat(48)}`; +const TOKEN_HASH = "f".repeat(64); + +function req(body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/c/${TOKEN}/unlock`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function call() { + return POST(req({ passphrase: "ABCD-EFGH-JKMN-PQRS" }), { + params: Promise.resolve({ token: TOKEN }), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + }); +}); + +describe("POST /api/c/[token]/unlock", () => { + it("sets a token-scoped httpOnly cookie on a correct passphrase", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(verifyPassphrase).mockReturnValue(true); + + const res = await call(); + expect(res.status).toBe(200); + + expect(cookieSet).toHaveBeenCalledTimes(1); + const [name, , opts] = cookieSet.mock.calls[0]; + expect(name).toContain("hls_unlock_"); + expect(opts.httpOnly).toBe(true); + expect(opts.sameSite).toBe("strict"); + // Scoped to THIS token's view path only. + expect(opts.path).toBe(`/c/${TOKEN}`); + expect(opts.maxAge).toBeGreaterThan(0); + expect(opts.maxAge).toBeLessThanOrEqual(30 * 60); + }); + + it("rejects a wrong passphrase with a blunt 401, no cookie", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(verifyPassphrase).mockReturnValue(false); + + const res = await call(); + expect(res.status).toBe(401); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("rejects an unknown / revoked / expired token (gate null) with 401", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue(null); + + const res = await call(); + expect(res.status).toBe(401); + expect(verifyPassphrase).not.toHaveBeenCalled(); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("cannot unlock a link with no passphrase set", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: null, + }); + // verifyPassphrase returns false for a null stored hash. + vi.mocked(verifyPassphrase).mockReturnValue(false); + + const res = await call(); + expect(res.status).toBe(401); + expect(cookieSet).not.toHaveBeenCalled(); + }); + + it("is rate-limited before any compare", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "deadbeef", + }); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + ip: "1.2.3.4", + }); + + const res = await call(); + expect(res.status).toBe(429); + expect(verifyPassphrase).not.toHaveBeenCalled(); + expect(cookieSet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/c/[token]/unlock/route.ts b/src/app/api/c/[token]/unlock/route.ts new file mode 100644 index 000000000..8cd027811 --- /dev/null +++ b/src/app/api/c/[token]/unlock/route.ts @@ -0,0 +1,106 @@ +/** + * v1.18.7 — POST /api/c/{token}/unlock — public passphrase gate verifier. + * + * The second factor for a passphrase-protected clinician share link. It is an + * anonymous, no-session surface (like the `/c/` view itself): the raw + * path token plus the submitted passphrase are the only credentials. On a + * correct passphrase it mints a SHORT-LIVED, httpOnly, SameSite=Strict, Secure + * cookie SCOPED to this token's view path so the page renders the record; a + * cookie minted for one token never unlocks another. + * + * Brute-force protection is mandatory here — the passphrase is the only thing + * standing between a leaked URL and the record — so the route is rate-limited + * by `share-unlock::` BEFORE any compare, using the + * anonymous-surface limiter so a broken trust chain collapses to one tight + * bucket rather than a free-for-all. + * + * Failure is blunt: a malformed token, an unknown/revoked/expired link, a link + * with no passphrase set, or a wrong passphrase all answer the SAME flat error. + * `no-store` is preserved end to end. Nothing here leaks why a request failed. + */ +import { cookies } from "next/headers"; +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token"; +import { verifyPassphrase } from "@/lib/clinician-share/passphrase"; +import { + mintUnlockValue, + unlockCookieName, + UNLOCK_TTL_SECONDS, +} from "@/lib/clinician-share/unlock-cookie"; +import { unlockShareLinkSchema } from "@/lib/validations/clinician-share-link"; + +/** One blunt failure for every class — a probe distinguishes nothing. */ +function reject() { + return apiError("Invalid passphrase", 401); +} + +export const POST = apiHandler( + async ( + request: NextRequest, + ctx: { params: Promise<{ token: string }> }, + ) => { + const { token } = await ctx.params; + annotate({ action: { name: "share-link.unlock" } }); + + // Resolve the live-gate state first (no counter bump). A malformed / + // unknown / revoked / expired token is `null` → the same blunt reject. + const gate = await resolveShareGateState(token); + + // Rate-limit BEFORE any compare. Key on the resolved tokenHash when we + // have one (so the bucket is per-link), else a fixed sentinel so probing + // unknown tokens still shares one tight bucket. The anonymous-surface + // limiter collapses a broken trust chain to a single bucket. + const rlKey = gate ? gate.tokenHash : "unknown"; + const rl = await checkAuthSurfaceRateLimit( + request, + `share-unlock:${rlKey}`, + 10, + 10 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Too many attempts", 429); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = unlockShareLinkSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + // A token with no live gate, or one carrying no passphrase, cannot be + // unlocked. `verifyPassphrase` is constant-time and returns false for a + // null stored hash, so the two branches are indistinguishable in timing. + if (!gate) return reject(); + if (!verifyPassphrase(parsed.data.passphrase, gate.passphraseHash)) { + annotate({ meta: { unlock: "rejected" } }); + return reject(); + } + + // Correct passphrase — mint the short-lived, token-scoped unlock cookie. + const cookieStore = await cookies(); + cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "strict", + // Scope to THIS token's view path only — the cookie unlocks nothing else. + path: `/c/${token}`, + maxAge: UNLOCK_TTL_SECONDS, + }); + + annotate({ meta: { unlock: "granted" } }); + return apiSuccess({ unlocked: true }); + }, +); diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 2403a4a28..e6d201cf3 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -1,16 +1,25 @@ /** * v1.11.0 (Epic C, C5) — public clinician-view page gating. + * v1.18.7 — passphrase gate: the page checks gate state first, gates a + * protected link until the unlock cookie is valid, and never bumps access + * counters on a gate-blocked hit. * - * Asserts the page answers a flat `notFound()` (404) whenever the resolver - * yields null — the single blunt response for unknown / revoked / expired / - * malformed tokens — and renders the scoped view on a successful resolve. + * Asserts the page answers a flat `notFound()` (404) whenever the gate + * resolver yields null — the single blunt response for unknown / revoked / + * expired / malformed tokens; renders the scoped view for a legacy (no + * passphrase) link and for a protected link with a valid unlock cookie; and + * shows the gate (no record load) for a protected link without the cookie. */ import { describe, it, expect, vi, beforeEach } from "vitest"; +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + class NotFoundError extends Error { digest = "NEXT_HTTP_ERROR_FALLBACK;404"; } +let cookieValue: string | undefined; + vi.mock("next/navigation", () => ({ notFound: vi.fn(() => { throw new NotFoundError("NEXT_NOT_FOUND"); @@ -18,10 +27,11 @@ vi.mock("next/navigation", () => ({ })); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined })), + cookies: vi.fn(async () => ({ get: () => ({ value: cookieValue }) })), })); vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ resolveShareToken: vi.fn(), + resolveShareGateState: vi.fn(), })); vi.mock("@/lib/clinician-share/share-view-data", () => ({ loadShareViewData: vi.fn(), @@ -29,47 +39,113 @@ vi.mock("@/lib/clinician-share/share-view-data", () => ({ vi.mock("@/components/clinician/clinician-view", () => ({ ClinicianView: () => null, })); +vi.mock("@/components/clinician/share-unlock-gate", () => ({ + ShareUnlockGate: () => "GATE", +})); import ClinicianSharePage from "../page"; -import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token"; +import { + resolveShareToken, + resolveShareGateState, +} from "@/lib/clinician-share/resolve-share-token"; import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; +import { mintUnlockValue } from "@/lib/clinician-share/unlock-cookie"; import { notFound } from "next/navigation"; const resolve = resolveShareToken as ReturnType; +const gateState = resolveShareGateState as ReturnType; const loadData = loadShareViewData as ReturnType; +const TOKEN_HASH = "f".repeat(64); + function pageProps(token: string) { return { params: Promise.resolve({ token }) }; } +/** The page returns a `` element when it gates. */ +function isGate(out: unknown): boolean { + return ( + typeof out === "object" && + out !== null && + "props" in out && + typeof (out as { props?: { token?: unknown } }).props?.token === "string" + ); +} + +function resolvedContext() { + return { + shareLinkId: "link-1", + ownerUserId: "owner-1", + label: "Clinic", + rangeStart: new Date(), + rangeEnd: null, + sectionsJson: {}, + resourceTypes: [], + allowFhirApi: false, + expiresAt: new Date(Date.now() + 86_400_000), + }; +} + describe("clinician share page", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + cookieValue = undefined; + }); it("404s when the token resolves to null (revoked / expired / unknown)", async () => { - resolve.mockResolvedValue(null); + gateState.mockResolvedValue(null); await expect( ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)), ).rejects.toBeInstanceOf(NotFoundError); expect(notFound).toHaveBeenCalledTimes(1); - // No data load is attempted on a failed resolve. + // No data load and no counter-bumping resolve on a failed gate. expect(loadData).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); }); - it("loads the scoped view on a successful resolve", async () => { - resolve.mockResolvedValue({ - shareLinkId: "link-1", - ownerUserId: "owner-1", - label: "Clinic", - rangeStart: new Date(), - rangeEnd: null, - sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, - expiresAt: new Date(Date.now() + 86_400_000), - }); + it("renders a legacy (no-passphrase) link without a gate", async () => { + gateState.mockResolvedValue({ tokenHash: TOKEN_HASH, passphraseHash: null }); + resolve.mockResolvedValue(resolvedContext()); loadData.mockResolvedValue({ report: {}, sections: {} }); await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`)); expect(notFound).not.toHaveBeenCalled(); expect(loadData).toHaveBeenCalledTimes(1); }); + + it("gates a protected link with NO unlock cookie (no record load)", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + const out = await ClinicianSharePage(pageProps(`hls_${"c".repeat(48)}`)); + // The gate island is returned; the record is never resolved or loaded. + expect(isGate(out)).toBe(true); + expect(resolve).not.toHaveBeenCalled(); + expect(loadData).not.toHaveBeenCalled(); + }); + + it("renders a protected link WITH a valid unlock cookie", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + cookieValue = mintUnlockValue(TOKEN_HASH); + resolve.mockResolvedValue(resolvedContext()); + loadData.mockResolvedValue({ report: {}, sections: {} }); + await ClinicianSharePage(pageProps(`hls_${"d".repeat(48)}`)); + expect(loadData).toHaveBeenCalledTimes(1); + }); + + it("gates a protected link with an INVALID / cross-token cookie", async () => { + gateState.mockResolvedValue({ + tokenHash: TOKEN_HASH, + passphraseHash: "stored-hash", + }); + // A cookie minted for a different token must not unlock this one. + cookieValue = mintUnlockValue("a".repeat(64)); + const out = await ClinicianSharePage(pageProps(`hls_${"e".repeat(48)}`)); + expect(isGate(out)).toBe(true); + expect(loadData).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index f88b8fb2e..acbca502c 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -19,8 +19,15 @@ import type { Metadata } from "next"; import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; -import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token"; +import { + resolveShareGateState, + resolveShareToken, +} from "@/lib/clinician-share/resolve-share-token"; import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; +import { + unlockCookieName, + verifyUnlockValue, +} from "@/lib/clinician-share/unlock-cookie"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, @@ -29,6 +36,7 @@ import { } from "@/lib/i18n/config"; import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; import { ClinicianView } from "@/components/clinician/clinician-view"; +import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate"; // Never cache a scoped health view — `no-store` end to end. export const dynamic = "force-dynamic"; @@ -61,9 +69,29 @@ export default async function ClinicianSharePage({ }) { const { token } = await params; - // The ONE trust boundary. No session is read; this proves only that the raw - // path token hashes to a live, in-window share link and yields the owner - // scope. Any failure → null → flat 404. + // v1.18.7 — gate check first, WITHOUT bumping access counters. Resolves a + // malformed / unknown / revoked / expired token to null → the same flat 404 + // the full read would. A legacy link (no passphrase) falls straight through. + const gate = await resolveShareGateState(token); + if (!gate) notFound(); + + if (gate.passphraseHash !== null) { + // Protected link: render nothing of the record until the short-lived, + // token-scoped unlock cookie is present and valid. Verifying the cookie is + // constant-time; an invalid / expired / cross-token value shows the gate. + const cookieStore = await cookies(); + const unlockValue = cookieStore.get(unlockCookieName(gate.tokenHash))?.value; + if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { + const locale = await resolveLocale(); + const { t } = getServerTranslator(locale); + return t(key, vars)} token={token} />; + } + } + + // The ONE trust boundary for the full read. No session is read; this proves + // only that the raw path token hashes to a live, in-window share link and + // yields the owner scope. Any failure → null → flat 404. The access counter + // is bumped here (on a real record render), not on a gate-blocked hit. const context = await resolveShareToken(token); if (!context) notFound(); diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx new file mode 100644 index 000000000..790357525 --- /dev/null +++ b/src/components/clinician/share-unlock-gate.tsx @@ -0,0 +1,130 @@ +"use client"; + +/** + * v1.18.7 — passphrase gate for a protected clinician share link. + * + * Renders ONLY when the server determined the link carries a passphrase and the + * short-lived unlock cookie is absent. The record is never sent to the client + * until the cookie is set — this island is the whole page until then. + * + * Two paths: + * - The owner shares a QR / deep link carrying `#k=` in the URL + * FRAGMENT (never sent to the server). On mount we read `location.hash`, + * auto-submit it, scrub the hash from history, and reload on success so the + * server re-renders the record with the now-set cookie. + * - No fragment → a localised passphrase input the clinician types into. + * + * The submit POSTs to `/api/c//unlock`; on success the server has set an + * httpOnly cookie scoped to this token's path, so a full reload paints the + * record. Failure shows one blunt localised error (the server leaks nothing). + */ +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { apiPost } from "@/lib/api/api-fetch"; + +type Translate = ( + key: string, + vars?: Record, +) => string; + +interface ShareUnlockGateProps { + t: Translate; + /** The raw `hls_` token from the path — used only to build the verify URL. */ + token: string; +} + +export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { + const [passphrase, setPassphrase] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + const autoTried = useRef(false); + + async function submit(value: string): Promise { + const trimmed = value.trim(); + if (!trimmed) return; + setPending(true); + setError(false); + try { + await apiPost(`/api/c/${encodeURIComponent(token)}/unlock`, { + passphrase: trimmed, + }); + // Cookie is set server-side; a full reload re-renders with the record. + window.location.reload(); + } catch { + setError(true); + setPending(false); + } + } + + // Auto-unlock from the `#k=` fragment, once. + useEffect(() => { + if (autoTried.current) return; + autoTried.current = true; + const hash = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(hash); + const k = params.get("k"); + if (k) { + // Scrub the secret from the address bar / history before the request. + history.replaceState(null, "", window.location.pathname); + // Defer out of the effect body so the submit's setState does not run + // synchronously inside the effect (no cascading render). + queueMicrotask(() => void submit(k)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
{ + e.preventDefault(); + void submit(passphrase); + }} + > +
+

+ {t("clinicianView.unlock.title")} +

+

+ {t("clinicianView.unlock.description")} +

+
+ +
+ + setPassphrase(e.target.value)} + placeholder="XXXX-XXXX-XXXX-XXXX" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + aria-invalid={error} + className="font-mono" + /> +
+ + {error && ( +

+ {t("clinicianView.unlock.error")} +

+ )} + + +
+
+ ); +} diff --git a/src/lib/clinician-share/__tests__/passphrase.test.ts b/src/lib/clinician-share/__tests__/passphrase.test.ts new file mode 100644 index 000000000..1a7fd2f6c --- /dev/null +++ b/src/lib/clinician-share/__tests__/passphrase.test.ts @@ -0,0 +1,87 @@ +/** + * v1.18.7 — share-link passphrase second factor. + * + * Asserts: generated passphrases match the grouped human-typeable form and are + * high-entropy / unique; normalisation collapses grouped and bare forms to one + * canonical hash; verification is correct, rejects a wrong passphrase, and a + * null stored hash; a malformed submission is rejected without throwing. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +beforeEach(() => { + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); +}); + +import { + generatePassphrase, + hashPassphrase, + normalisePassphrase, + verifyPassphrase, + PASSPHRASE_PATTERN, +} from "../passphrase"; + +describe("generatePassphrase", () => { + it("emits the grouped XXXX-XXXX-XXXX-XXXX form", () => { + const p = generatePassphrase(); + expect(p).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/); + // The bare form is 16 Crockford-base32 chars (80 bits). + expect(normalisePassphrase(p)).toMatch(PASSPHRASE_PATTERN); + }); + + it("excludes the easily-confused I/L/O/U letters", () => { + for (let i = 0; i < 50; i++) { + expect(generatePassphrase()).not.toMatch(/[ILOU]/); + } + }); + + it("is unique across a sample (high entropy)", () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generatePassphrase()); + expect(seen.size).toBe(200); + }); +}); + +describe("normalisePassphrase", () => { + it("collapses grouped, spaced, and lowercase forms to one canonical value", () => { + const p = generatePassphrase(); + const bare = normalisePassphrase(p)!; + expect(normalisePassphrase(bare)).toBe(bare); + expect(normalisePassphrase(p.toLowerCase())).toBe(bare); + expect(normalisePassphrase(p.replace(/-/g, " "))).toBe(bare); + }); + + it("rejects a malformed value", () => { + expect(normalisePassphrase("not a passphrase!")).toBeNull(); + expect(normalisePassphrase("")).toBeNull(); + // Contains an excluded letter. + expect(normalisePassphrase("IIII-IIII-IIII-IIII")).toBeNull(); + }); +}); + +describe("verifyPassphrase", () => { + it("accepts the correct passphrase against its stored hash", () => { + const p = generatePassphrase(); + const hash = hashPassphrase(normalisePassphrase(p)!); + expect(verifyPassphrase(p, hash)).toBe(true); + // Grouped vs bare vs lowercase all verify against the one hash. + expect(verifyPassphrase(normalisePassphrase(p)!, hash)).toBe(true); + expect(verifyPassphrase(p.toLowerCase(), hash)).toBe(true); + }); + + it("rejects a wrong passphrase", () => { + const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!); + expect(verifyPassphrase(generatePassphrase(), hash)).toBe(false); + }); + + it("rejects against a null stored hash (legacy / unset)", () => { + expect(verifyPassphrase(generatePassphrase(), null)).toBe(false); + }); + + it("rejects a malformed submission without throwing", () => { + const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!); + expect(verifyPassphrase("garbage", hash)).toBe(false); + expect(verifyPassphrase("", hash)).toBe(false); + }); +}); diff --git a/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts b/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts new file mode 100644 index 000000000..20d2a7a09 --- /dev/null +++ b/src/lib/clinician-share/__tests__/resolve-share-gate-state.test.ts @@ -0,0 +1,86 @@ +/** + * v1.18.7 — share-link gate state (the page's gate-vs-render decision). + * + * Asserts: a legacy (null passphraseHash) live link resolves WITHOUT a gate; + * a protected link surfaces its passphraseHash for the gate; revoked / expired + * / unknown / malformed tokens all resolve to null (the same blunt 404); the + * gate resolver NEVER bumps access counters (no `update` call). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLink: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + }, +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); + +import { resolveShareGateState } from "../resolve-share-token"; +import { prisma } from "@/lib/db"; + +const findUnique = prisma.clinicianShareLink.findUnique as ReturnType< + typeof vi.fn +>; +const update = prisma.clinicianShareLink.update as ReturnType; + +const VALID_TOKEN = `hls_${"a".repeat(48)}`; + +function row(overrides: Record = {}) { + return { + passphraseHash: "stored-hash", + revokedAt: null, + expiresAt: new Date(Date.now() + 86_400_000), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveShareGateState", () => { + it("returns null for a malformed token (no DB hit)", async () => { + expect(await resolveShareGateState("nope")).toBeNull(); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("returns null for an unknown token", async () => { + findUnique.mockResolvedValue(null); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("returns null for a revoked link", async () => { + findUnique.mockResolvedValue(row({ revokedAt: new Date() })); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("returns null for an expired link", async () => { + findUnique.mockResolvedValue(row({ expiresAt: new Date(Date.now() - 1000) })); + expect(await resolveShareGateState(VALID_TOKEN)).toBeNull(); + }); + + it("surfaces the passphraseHash for a protected live link", async () => { + findUnique.mockResolvedValue(row({ passphraseHash: "stored-hash" })); + const gate = await resolveShareGateState(VALID_TOKEN); + expect(gate).toEqual({ + tokenHash: `hash(${VALID_TOKEN})`, + passphraseHash: "stored-hash", + }); + }); + + it("resolves a legacy (null passphraseHash) live link WITHOUT a gate", async () => { + findUnique.mockResolvedValue(row({ passphraseHash: null })); + const gate = await resolveShareGateState(VALID_TOKEN); + expect(gate).not.toBeNull(); + expect(gate!.passphraseHash).toBeNull(); + }); + + it("never bumps the access counter (no update)", async () => { + findUnique.mockResolvedValue(row()); + await resolveShareGateState(VALID_TOKEN); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/clinician-share/__tests__/unlock-cookie.test.ts b/src/lib/clinician-share/__tests__/unlock-cookie.test.ts new file mode 100644 index 000000000..ccb2f58ae --- /dev/null +++ b/src/lib/clinician-share/__tests__/unlock-cookie.test.ts @@ -0,0 +1,62 @@ +/** + * v1.18.7 — short-lived, token-scoped unlock cookie. + * + * Asserts: a minted value verifies against its own token hash; it does NOT + * verify against a different token hash (cross-token isolation); an expired + * value is rejected; a forged / malformed value is rejected; the cookie name + * is per-token. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +beforeEach(() => { + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +import { + mintUnlockValue, + verifyUnlockValue, + unlockCookieName, + UNLOCK_TTL_SECONDS, +} from "../unlock-cookie"; + +const HASH_A = "a".repeat(64); +const HASH_B = "b".repeat(64); + +describe("unlock-cookie", () => { + it("verifies a freshly-minted value against its own token hash", () => { + const value = mintUnlockValue(HASH_A); + expect(verifyUnlockValue(value, HASH_A)).toBe(true); + }); + + it("does NOT verify against a different token hash (cross-token isolation)", () => { + const value = mintUnlockValue(HASH_A); + expect(verifyUnlockValue(value, HASH_B)).toBe(false); + }); + + it("rejects an expired value", () => { + vi.useFakeTimers(); + const value = mintUnlockValue(HASH_A); + // Advance past the TTL. + vi.advanceTimersByTime((UNLOCK_TTL_SECONDS + 60) * 1000); + expect(verifyUnlockValue(value, HASH_A)).toBe(false); + }); + + it("rejects a forged or malformed value", () => { + expect(verifyUnlockValue(undefined, HASH_A)).toBe(false); + expect(verifyUnlockValue("", HASH_A)).toBe(false); + expect(verifyUnlockValue("nodot", HASH_A)).toBe(false); + expect(verifyUnlockValue(`${Date.now() + 1_000_000}.deadbeef`, HASH_A)).toBe( + false, + ); + }); + + it("derives a per-token cookie name", () => { + expect(unlockCookieName(HASH_A)).not.toBe(unlockCookieName(HASH_B)); + expect(unlockCookieName(HASH_A)).toMatch(/^hls_unlock_/); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index c93c7a666..7cd5119e2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -42,6 +42,12 @@ const PUBLIC_PATHS = [ // by a session cookie, so it must reach the page without an auth gate. // The page renders a flat 404 for any unknown / revoked / expired token. "/c/", + // v1.18.7 — `/api/c//unlock` is the public passphrase-gate verifier + // for a protected share link. Like the view it carries no session — the raw + // path token plus the submitted passphrase are the only credentials — so it + // must reach the route handler without an auth gate. It is rate-limited and + // answers one blunt error for every failure class. + "/api/c/", // v1.17.0 — `/invite/` is the invite universal-link landing // (iOS #16). It is a thin shape-validated redirect onto // `/auth/register?invite=…`, carries no session, touches no database, From d75a5386147b3b19aaa1354ac54b527444ea169b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:53 +0200 Subject: [PATCH 4/5] Show the QR, passphrase, and protected badge in the owner UI On a successful create the Sharing section now renders the QR code (from the #k= deep link), the passphrase as copyable text, and the link, with a clear shown-once notice. The active-link list carries a small passphrase indicator. Add the new strings (QR label, passphrase label, copy buttons, hints, protected badge, and the public unlock prompt) across all six locale bundles. --- messages/de.json | 20 +- messages/en.json | 20 +- messages/es.json | 20 +- messages/fr.json | 20 +- messages/it.json | 20 +- messages/pl.json | 20 +- src/components/settings/sharing-section.tsx | 201 +++++++++++++++----- 7 files changed, 261 insertions(+), 60 deletions(-) diff --git a/messages/de.json b/messages/de.json index a70f807a6..366e5ba2e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4676,7 +4676,16 @@ "statusActive": "Aktiv", "statusRevoked": "Widerrufen", "statusExpired": "Abgelaufen", - "inactiveTitle": "Widerrufen und abgelaufen ({count})" + "inactiveTitle": "Widerrufen und abgelaufen ({count})", + "shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.", + "qrLabel": "QR-Code", + "qrAlt": "QR-Code mit Freigabelink und Passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.", + "copyLink": "Link kopieren", + "copyPassphrase": "Passphrase kopieren", + "protected": "Passphrase" }, "genderOther": "Divers", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Belastungswert", "score": "Wellness-Wert" }, - "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker." + "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.", + "unlock": { + "title": "Passphrase eingeben", + "description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.", + "label": "Passphrase", + "error": "Diese Passphrase ist nicht korrekt.", + "submit": "Entsperren" + } }, "cycle": { "symptomCategory": { diff --git a/messages/en.json b/messages/en.json index 551a46efe..45e6a3f98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4676,7 +4676,16 @@ "statusActive": "Active", "statusRevoked": "Revoked", "statusExpired": "Expired", - "inactiveTitle": "Revoked and expired ({count})" + "inactiveTitle": "Revoked and expired ({count})", + "shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.", + "qrLabel": "QR code", + "qrAlt": "QR code containing the share link and passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.", + "copyLink": "Copy link", + "copyPassphrase": "Copy passphrase", + "protected": "Passphrase" }, "genderOther": "Other", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Strain score", "score": "Wellness score" }, - "footer": "Shared from HealthLog — a self-hosted personal health tracker." + "footer": "Shared from HealthLog — a self-hosted personal health tracker.", + "unlock": { + "title": "Enter passphrase", + "description": "This shared health record is protected. Enter the passphrase you were given to view it.", + "label": "Passphrase", + "error": "That passphrase is not correct.", + "submit": "Unlock" + } }, "cycle": { "symptomCategory": { diff --git a/messages/es.json b/messages/es.json index c4c7cc722..983170479 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4676,7 +4676,16 @@ "statusActive": "Activo", "statusRevoked": "Revocado", "statusExpired": "Caducado", - "inactiveTitle": "Revocados y caducados ({count})" + "inactiveTitle": "Revocados y caducados ({count})", + "shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.", + "qrLabel": "Código QR", + "qrAlt": "Código QR con el enlace para compartir y la frase de acceso", + "linkLabel": "Enlace", + "passphraseLabel": "Frase de acceso", + "passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.", + "copyLink": "Copiar enlace", + "copyPassphrase": "Copiar frase de acceso", + "protected": "Frase de acceso" }, "genderOther": "Otro", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Puntuación de esfuerzo", "score": "Puntuación de bienestar" }, - "footer": "Compartido desde HealthLog — un registro de salud autoalojado." + "footer": "Compartido desde HealthLog — un registro de salud autoalojado.", + "unlock": { + "title": "Introduzca la frase de acceso", + "description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.", + "label": "Frase de acceso", + "error": "Esa frase de acceso no es correcta.", + "submit": "Desbloquear" + } }, "cycle": { "symptomCategory": { diff --git a/messages/fr.json b/messages/fr.json index 532b2cbf9..b79b3ce16 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4676,7 +4676,16 @@ "statusActive": "Actif", "statusRevoked": "Révoqué", "statusExpired": "Expiré", - "inactiveTitle": "Révoqués et expirés ({count})" + "inactiveTitle": "Révoqués et expirés ({count})", + "shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.", + "qrLabel": "Code QR", + "qrAlt": "Code QR contenant le lien de partage et la phrase secrète", + "linkLabel": "Lien", + "passphraseLabel": "Phrase secrète", + "passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.", + "copyLink": "Copier le lien", + "copyPassphrase": "Copier la phrase secrète", + "protected": "Phrase secrète" }, "genderOther": "Autre", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Score de charge", "score": "Score de bien-être" }, - "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé." + "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.", + "unlock": { + "title": "Saisir la phrase secrète", + "description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.", + "label": "Phrase secrète", + "error": "Cette phrase secrète est incorrecte.", + "submit": "Déverrouiller" + } }, "cycle": { "symptomCategory": { diff --git a/messages/it.json b/messages/it.json index 0e28f4af2..09a66cf1a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4676,7 +4676,16 @@ "statusActive": "Attivo", "statusRevoked": "Revocato", "statusExpired": "Scaduto", - "inactiveTitle": "Revocati e scaduti ({count})" + "inactiveTitle": "Revocati e scaduti ({count})", + "shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.", + "qrLabel": "Codice QR", + "qrAlt": "Codice QR contenente il link di condivisione e la passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.", + "copyLink": "Copia link", + "copyPassphrase": "Copia passphrase", + "protected": "Passphrase" }, "genderOther": "Altro", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Punteggio di sforzo", "score": "Punteggio di benessere" }, - "footer": "Condiviso da HealthLog — un tracker della salute self-hosted." + "footer": "Condiviso da HealthLog — un tracker della salute self-hosted.", + "unlock": { + "title": "Inserisci la passphrase", + "description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.", + "label": "Passphrase", + "error": "La passphrase non è corretta.", + "submit": "Sblocca" + } }, "cycle": { "symptomCategory": { diff --git a/messages/pl.json b/messages/pl.json index b528246d6..77f4a881d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4676,7 +4676,16 @@ "statusActive": "Aktywny", "statusRevoked": "Unieważniony", "statusExpired": "Wygasły", - "inactiveTitle": "Unieważnione i wygasłe ({count})" + "inactiveTitle": "Unieważnione i wygasłe ({count})", + "shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.", + "qrLabel": "Kod QR", + "qrAlt": "Kod QR zawierający link do udostępniania i hasło", + "linkLabel": "Link", + "passphraseLabel": "Hasło", + "passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.", + "copyLink": "Kopiuj link", + "copyPassphrase": "Kopiuj hasło", + "protected": "Hasło" }, "genderOther": "Inna", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Wynik obciążenia", "score": "Wynik dobrostanu" }, - "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia." + "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.", + "unlock": { + "title": "Wprowadź hasło", + "description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.", + "label": "Hasło", + "error": "To hasło jest nieprawidłowe.", + "submit": "Odblokuj" + } }, "cycle": { "symptomCategory": { diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 65f86d9cf..6a1efd874 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -21,9 +21,10 @@ * the share-links card. Cards paint through `` so the surface * matches every sibling section 1:1. */ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Copy, Loader2, Share2, Trash2 } from "lucide-react"; +import QRCode from "qrcode"; +import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react"; import { AlertDialog, @@ -71,6 +72,8 @@ interface ShareLinkSummary { rangeEnd: string | null; resourceTypes: string[]; allowFhirApi: boolean; + /** v1.18.7 — whether a passphrase second factor guards this link. */ + protected: boolean; expiresAt: string; createdAt: string; revokedAt: string | null; @@ -79,6 +82,18 @@ interface ShareLinkSummary { active: boolean; } +/** + * v1.18.7 — the create response. The token, passphrase, and URLs are returned + * EXACTLY ONCE here; the list query never carries them (the server stores only + * hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`). + */ +interface ShareLinkCreated extends ShareLinkSummary { + token: string; + passphrase: string; + shareUrl: string; + qrUrl: string; +} + export function SharingSection() { // v1.18.7 — the visible heading + subtitle come from the shared settings // shell chrome in the route; this body is the share-links card only. @@ -106,11 +121,34 @@ function ShareLinksCard() { "Patient", "Observation", ]); - const [newToken, setNewToken] = useState(null); - const [copied, setCopied] = useState(false); + const [created, setCreated] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [copied, setCopied] = useState<"link" | "passphrase" | null>(null); const [formError, setFormError] = useState(null); const [showRevoked, setShowRevoked] = useState(false); + // Render the QR from the `#k=` deep link once a link is created. The fragment + // carries the passphrase, so the QR alone opens the record — it is shown + // exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so + // a re-create swaps it; the effect only WRITES on async completion (never a + // synchronous reset), so it does not trigger a cascading render. + const qrUrl = created?.qrUrl ?? null; + useEffect(() => { + if (!qrUrl) return; + let cancelled = false; + QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }) + .then((url) => { + if (!cancelled) setQrDataUrl(url); + }) + .catch(() => { + /* clipboard/QR can fail in some contexts; the link + passphrase text + are still shown so the owner can share them by hand */ + }); + return () => { + cancelled = true; + }; + }, [qrUrl]); + const { data: links } = useQuery({ queryKey: queryKeys.shareLinks(), queryFn: () => @@ -128,27 +166,26 @@ function ShareLinksCard() { if (expiryDays < 1 || expiryDays > MAX_DAYS) { return Promise.reject(new Error("EXPIRY_RANGE")); } - return apiPost( - "/api/share-links", - { - label: trimmed, - rangeStart: isoDaysFromNow(-rangeDays), - rangeEnd: null, - resourceTypes, - allowFhirApi, - expiresAt: isoDaysFromNow(expiryDays), - }, - ); + return apiPost("/api/share-links", { + label: trimmed, + rangeStart: isoDaysFromNow(-rangeDays), + rangeEnd: null, + resourceTypes, + allowFhirApi, + expiresAt: isoDaysFromNow(expiryDays), + }); }, - onSuccess: (created) => { - setNewToken(created.token); - setCopied(false); + onSuccess: (result) => { + // Clear any stale QR before the effect renders the new one. + setQrDataUrl(null); + setCreated(result); + setCopied(null); setLabel(""); setFormError(null); queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); }, onError: (err: Error) => { - setNewToken(null); + setCreated(null); setFormError( err.message === "EXPIRY_RANGE" ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) @@ -170,15 +207,13 @@ function ShareLinksCard() { ); } - async function copyToken() { - if (!newToken) return; + async function copyValue(value: string, which: "link" | "passphrase") { try { - const origin = window.location.origin; - await navigator.clipboard.writeText(`${origin}/c/${newToken}`); - setCopied(true); + await navigator.clipboard.writeText(value); + setCopied(which); } catch { - // Clipboard can be unavailable (insecure context); the token stays - // visible in the card so the owner can copy it by hand. + // Clipboard can be unavailable (insecure context); the values stay + // visible in the card so the owner can copy them by hand. } } @@ -313,37 +348,97 @@ function ShareLinksCard() { - {newToken && ( + {created && (

{t("settings.sharing.tokenCreated")}

- {t("settings.sharing.tokenOnce")} + {t("settings.sharing.shownOnce")}

-
- - {`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`} - - -
- {copied && ( -

- {t("settings.sharing.copied")} -

+ + {/* QR — carries the passphrase in the URL fragment, so scanning it + opens the record. Shown exactly once with the passphrase text. */} + {qrDataUrl && ( +
+

+ {t("settings.sharing.qrLabel")} +

+ {/* The QR is a transient secret render, not stored content. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {t("settings.sharing.qrAlt")} +
)} + + {/* The link (no passphrase). On its own it cannot open the record. */} +
+

+ {t("settings.sharing.linkLabel")} +

+
+ + {created.shareUrl} + + +
+ {copied === "link" && ( +

+ {t("settings.sharing.copied")} +

+ )} +
+ + {/* The passphrase — the second factor. Shown once; only its hash is + stored, so it cannot be recovered. */} +
+

+ {t("settings.sharing.passphraseLabel")} +

+
+ + {created.passphrase} + + +
+ {copied === "passphrase" && ( +

+ {t("settings.sharing.copied")} +

+ )} +

+ {t("settings.sharing.passphraseHint")} +

+
)} @@ -373,6 +468,16 @@ function ShareLinksCard() { {t("settings.sharing.statusActive")} + {link.protected && ( + + + {t("settings.sharing.protected")} + + )} {link.allowFhirApi && ( FHIR From e05758957402420dd33221518c43d9909727cfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:59 +0200 Subject: [PATCH 5/5] Document the passphrase fields and unlock route in the API contract Extend the share-link create response with the passphrase, share URL, and QR deep link, and add the protected flag to the summary. Document the public POST /api/c/{token}/unlock passphrase-gate route. Regenerate the committed OpenAPI YAML to match. --- docs/api/openapi.yaml | 92 ++++++++++++++++++++++++- src/lib/openapi/routes/health-record.ts | 63 ++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 456cf79dc..18fbe4cd8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -989,7 +989,9 @@ paths: - Export summary: Create a clinician share link (v1.11.0) description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY - ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen + ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with + `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase + cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422." requestBody: @@ -1148,6 +1150,50 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/c/{token}/unlock: + post: + tags: + - Export + summary: Verify a share-link passphrase (v1.18.7, public) + description: "Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted + passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, + httpOnly cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every + failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401." + parameters: + - in: path + name: token + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + passphrase: + type: string + minLength: 1 + maxLength: 64 + required: + - passphrase + additionalProperties: false + responses: + "200": + description: Passphrase accepted; short-lived token-scoped unlock cookie set. + content: + application/json: + schema: + $ref: "#/components/schemas/ShareLinkUnlock" + "401": + description: Invalid passphrase (blunt — no failure detail leaked). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/fhir/metadata: get: tags: @@ -9701,6 +9747,8 @@ components: type: string allowFhirApi: type: boolean + protected: + type: boolean expiresAt: type: string createdAt: @@ -9720,6 +9768,16 @@ components: token: type: string description: Raw `hls_` token — returned ONCE and unrecoverable thereafter. + passphrase: + type: string + description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable. + shareUrl: + type: string + description: Absolute `/c/` URL (no passphrase). + qrUrl: + type: string + description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR + payload; the fragment is never sent to the server. required: - id - label @@ -9727,6 +9785,7 @@ components: - rangeEnd - resourceTypes - allowFhirApi + - protected - expiresAt - createdAt - revokedAt @@ -9734,6 +9793,9 @@ components: - accessCount - active - token + - passphrase + - shareUrl + - qrUrl additionalProperties: false error: type: "null" @@ -9774,6 +9836,8 @@ components: type: string allowFhirApi: type: boolean + protected: + type: boolean expiresAt: type: string createdAt: @@ -9797,6 +9861,7 @@ components: - rangeEnd - resourceTypes - allowFhirApi + - protected - expiresAt - createdAt - revokedAt @@ -9845,6 +9910,31 @@ components: - data - error additionalProperties: false + ShareLinkUnlock: + type: object + properties: + data: + type: object + properties: + unlocked: + type: boolean + enum: + - true + required: + - unlocked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false LoginResponse: type: object properties: diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 22c4e76eb..118d3884d 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -8,7 +8,10 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; -import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { + createShareLinkSchema, + unlockShareLinkSchema, +} from "@/lib/validations/clinician-share-link"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // v1.11.0 — clinician share-link owner-facing summary (never the raw token). @@ -19,6 +22,9 @@ const shareLinkSummary = z.object({ rangeEnd: z.string().nullable(), resourceTypes: z.array(z.string()), allowFhirApi: z.boolean(), + // v1.18.7 — whether a passphrase second factor guards this link. Always true + // for links created on v1.18.7+; false only for legacy links. + protected: z.boolean(), expiresAt: z.string(), createdAt: z.string(), revokedAt: z.string().nullable(), @@ -31,6 +37,18 @@ const shareLinkCreatedResponse = shareLinkSummary.extend({ token: z .string() .describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."), + // v1.18.7 — passphrase second factor + QR deep link, all returned ONCE. + passphrase: z + .string() + .describe( + "Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.", + ), + shareUrl: z.string().describe("Absolute `/c/` URL (no passphrase)."), + qrUrl: z + .string() + .describe( + "Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR payload; the fragment is never sent to the server.", + ), }); const shareLinkListResponse = z.object({ @@ -42,6 +60,11 @@ const shareLinkRevokedResponse = z.object({ revoked: z.boolean(), }); +// v1.18.7 — public passphrase-gate verify result. +const shareLinkUnlockResponse = z.object({ + unlocked: z.literal(true), +}); + export const healthRecordPaths: NonNullable = { "/api/export/health-record": { post: { @@ -80,7 +103,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Create a clinician share link (v1.11.0)", description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", requestBody: { required: true, content: { @@ -148,6 +171,42 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/api/c/{token}/unlock": { + post: { + tags: ["Export"], + summary: "Verify a share-link passphrase (v1.18.7, public)", + description: + "Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401.", + requestParams: { path: z.object({ token: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: unlockShareLinkSchema }, + }, + }, + responses: { + "200": { + description: + "Passphrase accepted; short-lived token-scoped unlock cookie set.", + content: { + "application/json": { + schema: dataEnvelope( + shareLinkUnlockResponse, + "ShareLinkUnlock", + ), + }, + }, + }, + // Spread the standard responses first, then override 401 with the + // share-specific blunt-reject description. + ...stdResponses, + "401": { + description: "Invalid passphrase (blunt — no failure detail leaked).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"],