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] 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;