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;
}
|