Vectrum
Sign In
96 lines · 3.5 KB · TypeScript
import { createHash, createHmac, randomBytes } from "node:crypto";

// Time-based one-time passwords (RFC 6238) for authenticator apps: HMAC-SHA1, 6 digits, a new code
// every 30 seconds. The secret is shared once, as a QR code of an otpauth:// URI.

const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const PERIOD = 30;
const DIGITS = 6;

export function base32Encode(bytes: Uint8Array) {
  let out = "";
  let bits = 0;
  let value = 0;
  for (const byte of bytes) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      out += BASE32[(value >>> (bits - 5)) & 31];
      bits -= 5;
    }
  }
  if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
  return out;
}

export function base32Decode(text: string) {
  const clean = text.toUpperCase().replace(/[\s=-]/g, "");
  const bytes: number[] = [];
  let bits = 0;
  let value = 0;
  for (const char of clean) {
    const index = BASE32.indexOf(char);
    if (index < 0) throw new Error("Invalid base32.");
    value = (value << 5) | index;
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >>> (bits - 8)) & 255);
      bits -= 8;
    }
  }
  return Buffer.from(bytes);
}

/** A new secret: 160 random bits, the size RFC 4226 recommends for SHA-1. */
export const newTotpSecret = () => base32Encode(randomBytes(20));

/** The HOTP code (RFC 4226) for a counter. */
export function hotp(key: Uint8Array, counter: number, digits = DIGITS) {
  const message = Buffer.alloc(8);
  message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
  message.writeUInt32BE(counter >>> 0, 4);
  const mac = createHmac("sha1", key).update(message).digest();
  const offset = mac[mac.length - 1] & 15;
  const number = mac.readUInt32BE(offset) & 0x7fffffff;
  return String(number % 10 ** digits).padStart(digits, "0");
}

export const totpStep = (now = Date.now()) => Math.floor(now / 1000 / PERIOD);

/**
 * The time step a code belongs to, or null if it's wrong. Codes from the step before and after
 * count too, for clocks that are a little off and codes typed just as they changed.
 */
export function matchTotp(secret: string, code: string, now = Date.now()) {
  if (!/^\d{6}$/.test(code)) return null;
  const key = base32Decode(secret);
  const current = totpStep(now);
  for (const step of [current, current - 1, current + 1]) {
    if (hotp(key, step) === code) return step;
  }
  return null;
}

/** What authenticator apps scan: otpauth://totp/Issuer:account?secret=…&issuer=Issuer */
export function otpauthUri(secret: string, issuer: string, account: string) {
  const label = encodeURIComponent(`${issuer}:${account}`);
  const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(PERIOD) });
  return `otpauth://totp/${label}?${params}`;
}

/** The secret in groups of four, easier to type into an app by hand. */
export const groupSecret = (secret: string) => secret.match(/.{1,4}/g)?.join(" ") ?? secret;

/** What the user typed, without the spaces and hyphens people add, lowercased. */
export const normalizeCode = (input: string) => input.replace(/[\s-]/g, "").toLowerCase();

/** One-time recovery codes, like "k3m9q-7xw2p": 50 random bits each. */
export function newRecoveryCodes(count = 10) {
  return Array.from({ length: count }, () => {
    const code = base32Encode(randomBytes(7)).slice(0, 10).toLowerCase();
    return `${code.slice(0, 5)}-${code.slice(5)}`;
  });
}

/** Recovery codes are stored as hashes of their normalized form. */
export const hashRecoveryCode = (code: string) => createHash("sha256").update(normalizeCode(code)).digest("hex");