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