mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user