All files / lib/rrm token.ts

100% Statements 38/38
100% Branches 20/20
100% Functions 6/6
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100                              8x     8x     954x 32004x 954x             33x 33x 33x   1x         493x             493x         493x         32x 29x 638x 29x                     463x 462x 461x                           50x   39x 39x 34x 34x   33x 33x   32x 32x    
/**
 * Visit tokens — the `?p=` value on a partner link.
 *
 * Shape: `base64url(prospectId).base64url(HMAC-SHA256(prospectId)).slice(0,22)`
 *
 * The token is deterministic, so it could always be recomputed rather than
 * stored. It is stored anyway, and `rrm_prospects.visit_token` is what makes
 * revocation possible: verification checks the HMAC *and* that the value still
 * matches the stored one. Without that second check, rotating the secret would
 * silently keep honouring every link ever sent, and there would be no way to
 * kill an individual link at all.
 *
 * WebCrypto only — same as packages/internal-auth. No node:crypto here.
 */
 
const encoder = new TextEncoder();
 
/** Truncation length of the signature segment. 22 base64url chars ≈ 132 bits. */
const SIG_LENGTH = 22;
 
function toBase64Url(bytes: Uint8Array): string {
	let binary = "";
	for (const byte of bytes) binary += String.fromCharCode(byte);
	return btoa(binary)
		.replace(/\+/g, "-")
		.replace(/\//g, "_")
		.replace(/=+$/, "");
}
 
function fromBase64Url(value: string): string | null {
	try {
		const padded = value.replace(/-/g, "+").replace(/_/g, "/");
		return atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
	} catch {
		return null;
	}
}
 
async function sign(prospectId: string, secret: string): Promise<string> {
	const key = await crypto.subtle.importKey(
		"raw",
		encoder.encode(secret),
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign"],
	);
	const signature = await crypto.subtle.sign(
		"HMAC",
		key,
		encoder.encode(prospectId),
	);
	return toBase64Url(new Uint8Array(signature)).slice(0, SIG_LENGTH);
}
 
/** Constant-time string compare. Length is not secret; content is. */
function timingSafeEqual(a: string, b: string): boolean {
	if (a.length !== b.length) return false;
	let diff = 0;
	for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
	return diff === 0;
}
 
/**
 * Minted once — at import, or at first enrolment for an organic prospect —
 * and stored on the prospect row.
 */
export async function mintVisitToken(
	prospectId: string,
	secret: string,
): Promise<string> {
	if (!prospectId) throw new Error("mintVisitToken: prospectId is required");
	if (!secret) throw new Error("mintVisitToken: secret is required");
	return `${toBase64Url(encoder.encode(prospectId))}.${await sign(prospectId, secret)}`;
}
 
/**
 * Verifies the signature and returns the prospect id, or null.
 *
 * This is only half the check. The caller MUST also confirm the token equals
 * `rrm_prospects.visit_token` for the returned id — see the note above. A
 * cryptographically valid token for a revoked link is still a revoked link.
 */
export async function verifyVisitToken(
	token: string | null | undefined,
	secret: string,
): Promise<string | null> {
	if (!token || !secret) return null;
 
	const parts = token.split(".");
	if (parts.length !== 2) return null;
	const [encodedId, signature] = parts;
	if (!encodedId || !signature) return null;
 
	const prospectId = fromBase64Url(encodedId);
	if (!prospectId) return null;
 
	const expected = await sign(prospectId, secret);
	return timingSafeEqual(signature, expected) ? prospectId : null;
}