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,
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.18.7 — clinician share-link passphrase second factor.
*
* A share URL is now possession AND knowledge: a leaked `/c/<token>` 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=<passphrase>`. */
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;
}
}
+32 -1
View File
@@ -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<ShareGateState | null> {
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 };
}
+81
View File
@@ -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/<token>`). 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: `<expiryMs>.<hmac>` where the HMAC is
* over `"<tokenHash>.<expiryMs>"` 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/<token>`) 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;
}
}
@@ -64,3 +64,16 @@ export const createShareLinkSchema = z
);
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
/**
* 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<typeof unlockShareLinkSchema>;