All files / src/lib auth.ts

100% Statements 25/25
100% Branches 10/10
100% Functions 4/4
100% Lines 19/19

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                                                                  21x 14x 14x             14x 14x                                                 21x 19x 5x 5x                     5x 2x 2x 128x   2x       14x 14x 448x 448x   14x    
/**
 * HMAC-SHA256 auth over room names, with the identity fallback the legacy
 * server uses when no KEY is configured.
 *
 * Legacy reference (`src/main.ls:23-26`):
 *
 * ```livescript
 * hmac = if !KEY then -> it else -> HMAC_CACHE[it] ||= do
 *   encoder = require \crypto .createHmac \sha256 (new Buffer KEY)
 *   encoder.update it.toString!
 *   encoder.digest \hex
 * ```
 *
 * Two behaviors preserved verbatim:
 *   1. When KEY is unset, `hmac(room) = room` (identity). This is exercised
 *      by the oracle recording `misc/get-edit-no-key-redirect.json` which
 *      302s to `?auth=<room-itself>`. See `tests/oracle/FINDINGS.md` F-03.
 *   2. When KEY is set, hex SHA-256 HMAC of the room string.
 *
 * We drop the in-memory cache — legacy was a Node module global; DO isolates
 * recycle, so cache hits would be rare. If it ever shows up in profiles,
 * fold the cache back in inside the DO instance. Meanwhile the Web Crypto
 * call is cheap (~5µs) relative to any round-trip it gates.
 *
 * Runtime note: this module uses Web Crypto (`crypto.subtle`) so it works
 * in both workerd (DO + Worker) and Node 20+ (the Node test harness).
 */
 
/**
 * Compute the HMAC hex for `room` under `key`. When `key` is undefined or
 * empty string, returns `room` unchanged (identity fallback).
 */
export async function computeAuth(key: string | undefined, room: string): Promise<string> {
  if (!key) return room;
  const enc = new TextEncoder();
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    enc.encode(key),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const signature = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(room));
  return bytesToHex(new Uint8Array(signature));
}
 
/**
 * Timing-safe comparison of a supplied `auth` value against the expected
 * HMAC.
 *
 * Ports the legacy gate at `src/main.ls:506`:
 *     return if auth is \0 or KEY and auth isnt hmac room
 *
 * Read positively:
 *   - Reject `supplied === '0'` unconditionally — the view-only sentinel
 *     `/:room` redirects to when KEY is set, which must never grant
 *     write access.
 *   - When `key` is unset, accept any other value (including empty
 *     string). This is the anonymous mode documented in CLAUDE.md §6.4
 *     and oracle F-03: identity HMAC means the legacy check reduces to
 *     "auth !== '0'".
 *   - When `key` is set, require a timing-safe match against the HMAC.
 */
export async function verifyAuth(
  key: string | undefined,
  room: string,
  supplied: string,
): Promise<boolean> {
  if (supplied === '0') return false;
  if (!key) return true;
  const expected = await computeAuth(key, room);
  return timingSafeEqualString(expected, supplied);
}
 
/**
 * Constant-time string compare. Returns false immediately on length
 * mismatch (which is itself non-secret information in our threat model —
 * the expected HMAC is a fixed 64 hex chars when KEY is set, so length
 * leaks nothing a timing-unaware attacker couldn't already deduce from
 * the protocol).
 */
function timingSafeEqualString(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;
}
 
function bytesToHex(bytes: Uint8Array): string {
  let s = '';
  for (let i = 0; i < bytes.length; i++) {
    const byte = bytes[i]!;
    s += (byte < 0x10 ? '0' : '') + byte.toString(16);
  }
  return s;
}