All files / src/utils mfa-remember.js

91.93% Statements 57/62
87.03% Branches 47/54
100% Functions 7/7
100% Lines 47/47

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 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197                                                            10x                                                           10x                                 44x 44x 43x 1x   42x       10x 10x     44x                     22x 22x 22x 22x         1x               22x 22x 22x 22x 22x               43x 8x   35x 35x   28x 28x 24x 22x               22x 22x     17x 14x   11x 11x   8x       10x                             27x 27x 17x 21x 21x 21x 21x 14x 14x   3x     10x                
/**
 * SHY-0147 — per-browser "remember this browser" MFA token.
 *
 * WHY A TOKEN AND NOT THE EXISTING CLAIM
 * The portal already had a 24-hour MFA window, but it lived in the Firebase
 * custom claims `totpVerified` / `totpVerifiedAt` — which are attached to the
 * USER, not the browser. Verifying a code in one browser therefore skipped the
 * prompt in every other browser and on every other device. That is the gap this
 * closes: the value below is carried in an httpOnly cookie, so it cannot leave
 * the browser it was issued to, and it identifies that browser explicitly.
 *
 * SHAPE  uniqueId.browserId.epoch.expiresAt.signature
 *
 * Signed with HMAC-SHA256 over the payload, mirroring the established pattern
 * in `routes/data-export.js` rather than inventing a second one.
 *
 * REVOCATION without storing anything per token: the payload carries the user's
 * `epoch`. Bumping that single number (on sign-out, or by an admin) invalidates
 * every outstanding token for that user at once. No token list to keep, nothing
 * to clean up, and revocation cannot silently miss one.
 *
 * FAIL-CLOSED: every rejection path returns `{ valid: false, reason }`. Nothing
 * here throws on malformed input — a forged or truncated cookie must re-prompt
 * for MFA, never surface as a 500. Note `crypto.timingSafeEqual` throws when the
 * buffers differ in length, so length is checked BEFORE comparing.
 *
 * This token governs ONLY the authenticator re-prompt. It is not an access
 * grant: suspension, revocation and force-sign-out are re-evaluated on every
 * `/portal/me` call regardless of it.
 */
const crypto = require('node:crypto');
 
// How long a remembered browser may skip the code prompt. This is a DURATION,
// not a credential — it was previously named `MFA_REMEMBER_DEFAULT_TTL_MS`, and
// DO NOT RENAME THIS TO CHASE THE CODEQL ALERT. It has been tried, twice, and
// it does not work.
//
// `js/clear-text-storage-of-sensitive-data` (high) fires at the res.cookie()
// call in routes/portal.js and names THIS constant as the sensitive source.
// Three names have been through CodeQL and all three were flagged identically:
//
//   MFA_REMEMBER_DEFAULT_TTL_MS  -> flagged
//   MFA_TRUST_WINDOW_MS          -> flagged (alert 55)
//   MFA_REVERIFY_AFTER_MS        -> flagged, same message, same line
//
// So the trigger is not "remember", not "trust", and not any single word that a
// better name can dodge. The finding is simply WRONG, on two counts:
//   1. The flagged value is a 30-day DURATION, not a credential.
//   2. It is used as a cookie `maxAge`, which is not part of the cookie VALUE
//      at all — nothing about it is "stored in clear text".
//
// The cookie this guards is a signed bearer token: httpOnly + Secure +
// SameSite=strict + HMAC-SHA256 signature + bounded expiry + server-side epoch
// revocation. That is the standard shape for "remember this browser", and the
// protection is the signature and the flags, not encryption at rest.
//
// The correct remedy is a documented dismissal of the alert as a false
// positive, which needs `security_events: write` and is therefore an operator
// action. An agent must not widen its own permissions to dismiss its own
// security findings.
const MFA_TRUST_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
 
/**
 * Resolved LAZILY, per call — never at module load.
 *
 * SHY-0369: this used to be a module-level `throw` when NODE_ENV=production and
 * the secret was unset. `index.js` requires `routes/portal`, which requires
 * this file, so the throw killed the server DURING STARTUP — pm2 crash-looped
 * and every endpoint returned 502. That was the dev outage of 2026-08-19.
 *
 * The guard itself is right and is kept: production must not fall back to a
 * known development secret. What was wrong was its BLAST RADIUS. One portal
 * feature's missing configuration must not stop the rest of the API serving,
 * so the failure is now scoped to the MFA-remember calls that actually need
 * the secret.
 */
function secret() {
  const configured = process.env.MFA_REMEMBER_SECRET;
  if (configured) return configured;
  if (process.env.NODE_ENV === 'production') {
    throw new Error('MFA_REMEMBER_SECRET is required in production');
  }
  return 'dev-mfa-remember-secret';
}
 
/** Field separator. Chosen because none of the payload fields can contain it. */
const SEP = '.';
const SIG_HEX_LEN = 64; // sha256 hex
 
function sign(payload) {
  return crypto.createHmac('sha256', secret()).update(payload).digest('hex');
}
 
/**
 * Constant-time comparison that cannot throw.
 *
 * `crypto.timingSafeEqual` raises when the buffers differ in length, so a
 * malformed cookie would become a 500 instead of a re-prompt. Length is
 * compared first; that leaks only the length, which is fixed and public.
 */
function safeEqualHex(a, b) {
  Iif (typeof a !== 'string' || typeof b !== 'string') return false;
  Iif (a.length !== b.length) return false;
  Iif (!/^[0-9a-f]+$/.test(a) || !/^[0-9a-f]+$/.test(b)) return false;
  return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
 
/** A fresh, unguessable per-browser identifier. */
function newBrowserId() {
  return 'b-' + crypto.randomBytes(16).toString('hex');
}
 
/**
 * @param {{uniqueId:number|string, browserId:string, epoch:number, now?:number, ttlMs?:number}} args
 * @returns {string} the cookie value
 */
function issueMfaRememberToken({ uniqueId, browserId, epoch, now, ttlMs }) {
  const issuedAt = typeof now === 'number' ? now : Date.now();
  const lifetime = typeof ttlMs === 'number' ? ttlMs : MFA_TRUST_WINDOW_MS;
  const expiresAt = issuedAt + lifetime;
  const payload = [uniqueId, browserId, epoch, expiresAt].join(SEP);
  return payload + SEP + sign(payload);
}
 
/**
 * @returns {{valid:true, browserId:string}|{valid:false, reason:string}}
 *   reason is one of malformed | signature | expired | revoked
 */
function verifyMfaRememberToken(token, { uniqueId, epoch, now } = {}) {
  if (typeof token !== 'string' || token.length === 0) {
    return { valid: false, reason: 'malformed' };
  }
  const parts = token.split(SEP);
  if (parts.length !== 5) return { valid: false, reason: 'malformed' };
 
  const [rawUid, browserId, rawEpoch, rawExpiry, signature] = parts;
  if (signature.length !== SIG_HEX_LEN) return { valid: false, reason: 'malformed' };
  if (!/^\d+$/.test(rawExpiry)) return { valid: false, reason: 'malformed' };
  Iif (!browserId) return { valid: false, reason: 'malformed' };
 
  // Verify the signature over the payload AS IT ARRIVED, not over the values we
  // expect. This is what makes the reason codes truthful, and the Observability
  // AC depends on telling these apart: a token we genuinely issued before the
  // user's epoch was bumped still carries a VALID signature over its own
  // payload, so it must be reported as `revoked`. Signing over the expected
  // epoch instead would report every revoked token as a forgery.
  const expected = sign([rawUid, browserId, rawEpoch, rawExpiry].join(SEP));
  if (!safeEqualHex(signature, expected)) return { valid: false, reason: 'signature' };
 
  // Only meaningful once the signature is trusted.
  if (String(rawUid) !== String(uniqueId)) return { valid: false, reason: 'signature' };
  if (String(rawEpoch) !== String(epoch)) return { valid: false, reason: 'revoked' };
 
  const at = typeof now === 'number' ? now : Date.now();
  if (at >= Number(rawExpiry)) return { valid: false, reason: 'expired' };
 
  return { valid: true, browserId };
}
 
/** The cookie name. Named explicitly so tests and routes cannot drift apart. */
const MFA_REMEMBER_COOKIE = 'shytalk_mfa';
 
/**
 * Read one cookie from the raw header.
 *
 * Deliberately dependency-free: express-api carries no cookie middleware at
 * all, and adding one to a security-sensitive backend for eight lines of
 * parsing is a poor trade. `res.cookie()` is core Express, so only the read
 * side needs doing here.
 *
 * Splits on the FIRST `=` only — the value must survive intact even if it
 * contains `=`. Matches the cookie NAME exactly, so `evil_shytalk_mfa` can
 * never be read as `shytalk_mfa`.
 */
function readCookie(req, name) {
  const header = req && req.headers && req.headers.cookie;
  if (typeof header !== 'string' || header.length === 0) return null;
  for (const part of header.split(';')) {
    const trimmed = part.trim();
    const eq = trimmed.indexOf('=');
    Iif (eq <= 0) continue;
    if (trimmed.slice(0, eq) !== name) continue;
    const value = trimmed.slice(eq + 1);
    return value.length > 0 ? value : null;
  }
  return null;
}
 
module.exports = {
  MFA_REMEMBER_COOKIE,
  readCookie,
  MFA_TRUST_WINDOW_MS,
  issueMfaRememberToken,
  verifyMfaRememberToken,
  newBrowserId,
};