mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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,
|
||||
|
||||
Reference in new issue
Block a user