SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.0 KB · 111 lines typescript
Raw Blame History
1import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';2import { promisify } from 'node:util';34const scrypt = promisify(scryptCb) as (password: string | Buffer, salt: Buffer, keylen: number, opts: { N: number; r: number; p: number; maxmem: number }) => Promise<Buffer>;56/** Zero-native-dependency password hashing: scrypt (N=2^15, r=8, p=1), self-describing format. */7const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 } as const;89export async function hashPassword(password: string): Promise<string> {10  const salt = randomBytes(16);11  const key = await scrypt(password.normalize('NFKC'), salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT.maxmem });12  return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64url')}$${key.toString('base64url')}`;13}1415export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {16  if (!stored) return false;17  const [algo, n, r, p, saltB64, hashB64] = stored.split('$');18  if (algo !== 'scrypt' || !n || !r || !p || !saltB64 || !hashB64) return false;19  const expected = Buffer.from(hashB64, 'base64url');20  const key = await scrypt(password.normalize('NFKC'), Buffer.from(saltB64, 'base64url'), expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem });21  return key.length === expected.length && timingSafeEqual(key, expected);22}2324function secret(): string {25  const s = process.env.SESSION_SECRET;26  if (!s || s.length < 16) throw new Error('SESSION_SECRET must be set (≥ 32 bytes recommended)');27  return s;28}2930/** Keyed hash for opaque tokens (session ids, device tokens, API keys, one-time codes). */31export function hmacToken(value: string, purpose = 'token'): string {32  return createHmac('sha256', secret()).update(`${purpose}:${value}`).digest('base64url');33}3435export function sha256Hex(value: string): string {36  return createHash('sha256').update(value).digest('hex');37}3839export function randomToken(bytes = 32): string {40  return randomBytes(bytes).toString('base64url');41}4243/** Uniformly random n-digit numeric code (no modulo bias: randomInt). */44export function numericCode(digits = 6): string {45  const max = 10 ** digits;46  return String(randomInt(0, max)).padStart(digits, '0');47}4849/** Human-friendly recovery code: 10 chars, groups of 5, unambiguous alphabet. */50export function recoveryCode(): string {51  const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';52  let out = '';53  for (let i = 0; i < 10; i++) out += alphabet[randomInt(0, alphabet.length)];54  return `${out.slice(0, 5)}-${out.slice(5)}`;55}5657export function normalizeRecoveryCode(input: string): string {58  return input.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{5})(.{5})$/, '$1-$2');59}6061function aesKey(): Buffer {62  return createHash('sha256').update(`aes:${secret()}`).digest();63}6465/** AES-256-GCM encryption for secrets at rest (TOTP seeds). */66export function encryptSecret(plain: string): string {67  const iv = randomBytes(12);68  const cipher = createCipheriv('aes-256-gcm', aesKey(), iv);69  const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);70  const tag = cipher.getAuthTag();71  return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${enc.toString('base64url')}`;72}7374export function decryptSecret(payload: string): string {75  const [v, ivB, tagB, encB] = payload.split('.');76  if (v !== 'v1' || !ivB || !tagB || !encB) throw new Error('bad secret payload');77  const decipher = createDecipheriv('aes-256-gcm', aesKey(), Buffer.from(ivB, 'base64url'));78  decipher.setAuthTag(Buffer.from(tagB, 'base64url'));79  return Buffer.concat([decipher.update(Buffer.from(encB, 'base64url')), decipher.final()]).toString('utf8');80}8182/** Signed, expiring compact token (pending-login state, reset links). Payload is JSON, base64url. */83export function signPayload(payload: Record<string, unknown>, ttlSeconds: number): string {84  const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');85  const sig = createHmac('sha256', secret()).update(body).digest('base64url');86  return `${body}.${sig}`;87}8889export function verifyPayload<T extends Record<string, unknown>>(token: string | null | undefined): (T & { exp: number }) | null {90  if (!token) return null;91  const [body, sig] = token.split('.');92  if (!body || !sig) return null;93  const expected = createHmac('sha256', secret()).update(body).digest('base64url');94  const a = Buffer.from(sig);95  const b = Buffer.from(expected);96  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;97  try {98    const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T & { exp: number };99    if (!parsed.exp || parsed.exp < Math.floor(Date.now() / 1000)) return null;100    return parsed;101  } catch {102    return null;103  }104}105106export function safeEqual(a: string, b: string): boolean {107  const ba = Buffer.from(a);108  const bb = Buffer.from(b);109  return ba.length === bb.length && timingSafeEqual(ba, bb);110}111