← back to Letsbegin
lib/session-token.ts
110 lines
/**
* session-token.ts — the HMAC-signed local ("letsbegin_session") session cookie.
*
* SECURITY (TK-11776 follow-up): the previous local session was an UNSIGNED
* base64(JSON.stringify({ authenticated: true, ... })) cookie that middleware.ts and
* /api/auth/session decoded and TRUSTED on the `authenticated` flag alone — so
* `btoa('{"authenticated":true,"loginTime":<now>}')` was a valid session on every gated
* route, with no signature (the same forgeable class TK-11776 #4 closed for the DW Central
* cookie). This signs the payload with HMAC-SHA256 under DW_SESSION_SECRET so only a
* secret-holder can mint one.
*
* Envelope: token = <payloadB64> + "." + <sigB64>
* payload = utf8(JSON.stringify(state)) // the session object, unchanged
* sig = HMAC-SHA256( ascii(payloadB64), DW_SESSION_SECRET )
* Standard base64 has no ".", so lastIndexOf('.') splits cleanly and every legacy unsigned
* base64(JSON) cookie (which has no ".") is REJECTED.
*
* Runtime-agnostic (Web Crypto + btoa/atob): works in the Edge middleware runtime, in
* Node 18+ (the route handlers), and in jest's node env. crypto.subtle.verify is itself
* constant-time. Fails CLOSED: sign() throws on a missing secret (never mint an unsigned
* token); verify() returns null on a missing secret, a missing/bad signature, or any decode
* error. This intentionally does NOT reuse dw-central-session.ts — that module is the
* lockstep-copied DW Central `username:timestamp` / 24h contract; this is the local
* `letsbegin_session` JSON / 30-day session, a separate concern.
*/
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function utf8ToBase64(str: string): string {
return bytesToBase64(new TextEncoder().encode(str));
}
function base64ToUtf8(b64: string): string {
return new TextDecoder().decode(base64ToBytes(b64));
}
// Uint8Array -> BufferSource: a runtime no-op cast to satisfy crypto.subtle's typing across
// tsconfigs (same shim as dw-central-session.ts).
function bs(u: Uint8Array): BufferSource {
return u as unknown as BufferSource;
}
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
bs(new TextEncoder().encode(secret)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
}
/**
* Sign a session state object into a tamper-evident token. Throws if the secret is unset —
* an unsigned session token must never be minted.
*/
export async function signSession(
state: Record<string, unknown>,
secret: string | undefined,
): Promise<string> {
if (!secret) throw new Error('DW_SESSION_SECRET is required to sign a session');
const payloadB64 = utf8ToBase64(JSON.stringify(state));
const key = await hmacKey(secret);
const sig = new Uint8Array(
await crypto.subtle.sign('HMAC', key, bs(new TextEncoder().encode(payloadB64))),
);
return `${payloadB64}.${bytesToBase64(sig)}`;
}
/**
* Verify a session token and return its decoded state, or null when the signature is
* invalid / secret unset / token malformed. Callers still enforce their own semantics
* (e.g. `authenticated === true`, age window) on the returned object.
*/
export async function verifySession<T = Record<string, unknown>>(
token: string | undefined | null,
secret: string | undefined,
): Promise<T | null> {
try {
if (!secret) return null; // fail closed — never accept-all
if (!token || typeof token !== 'string') return null;
const dot = token.lastIndexOf('.');
if (dot <= 0 || dot >= token.length - 1) return null; // rejects legacy unsigned base64(JSON) cookies
const payloadB64 = token.slice(0, dot);
const sigB64 = token.slice(dot + 1);
if (!payloadB64 || !sigB64) return null;
const key = await hmacKey(secret);
const ok = await crypto.subtle.verify( // constant-time HMAC verification
'HMAC',
key,
bs(base64ToBytes(sigB64)),
bs(new TextEncoder().encode(payloadB64)),
);
if (!ok) return null;
return JSON.parse(base64ToUtf8(payloadB64)) as T;
} catch {
return null;
}
}