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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:03:36 +02:00
1 parent 53d41c190b
commit 1aeb6f7957
6 files changed
+327 -3

No files matched your search

@@ -75,6 +75,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
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,
+54 -2
View File
@@ -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,