diff --git a/app/server/src/auth/__tests__/backup-codes.test.ts b/app/server/src/auth/__tests__/backup-codes.test.ts new file mode 100644 index 0000000..d0a894e --- /dev/null +++ b/app/server/src/auth/__tests__/backup-codes.test.ts @@ -0,0 +1,100 @@ +// Tests for backup-codes module. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateBackupCodes, hashBackupCode, verifyBackupCode } from '../backup-codes.ts'; + +describe('generateBackupCodes', () => { + it('returns exactly n unique codes by default (10)', () => { + const codes = generateBackupCodes(); + assert.equal(codes.length, 10); + const unique = new Set(codes); + assert.equal(unique.size, 10); + }); + + it('respects the requested count', () => { + const codes = generateBackupCodes(25); + assert.equal(codes.length, 25); + const unique = new Set(codes); + assert.equal(unique.size, 25); + }); + + it('each code matches the XXXX-XXXX hex format', () => { + const codes = generateBackupCodes(50); + for (const code of codes) { + assert.match(code, /^[0-9a-f]{4}-[0-9a-f]{4}$/); + } + }); + + it('100 codes still have no collisions', () => { + const codes = generateBackupCodes(100); + const unique = new Set(codes); + assert.equal(unique.size, 100); + }); +}); + +describe('hashBackupCode', () => { + it('produces a string starting with "scrypt$"', () => { + const hash = hashBackupCode('a3f8-1b2c'); + assert.ok(hash.startsWith('scrypt$')); + }); + + it('follows the scrypt$$ format', () => { + const hash = hashBackupCode('a3f8-1b2c'); + const parts = hash.split('$'); + assert.equal(parts.length, 3); + assert.equal(parts[0], 'scrypt'); + // 16-byte salt → 32 hex chars; 64-byte hash → 128 hex chars + assert.equal(parts[1].length, 32); + assert.equal(parts[2].length, 128); + }); + + it('different calls produce different salts (not deterministic)', () => { + const hashes = new Set(); + for (let i = 0; i < 5; i++) hashes.add(hashBackupCode('a3f8-1b2c')); + // Extremely unlikely to all be the same; assert at least 2 distinct + assert.ok(hashes.size >= 2); + }); +}); + +describe('verifyBackupCode', () => { + it('returns true for a valid code and its hash', () => { + const code = generateBackupCodes(1)[0]; + const hash = hashBackupCode(code); + assert.ok(verifyBackupCode(code, [hash])); + }); + + it('returns false for a wrong code against its own hash slot', () => { + const correctCode = generateBackupCodes(1)[0]; + const hash = hashBackupCode(correctCode); + const wrongCode = '0000-0000'; + assert.ok(!verifyBackupCode(wrongCode, [hash])); + }); + + it('returns true when the correct code is anywhere in the stored array', () => { + const codes = generateBackupCodes(3); + const hashes = codes.map(hashBackupCode); + // Middle element + assert.ok(verifyBackupCode(codes[1], hashes)); + // First element + assert.ok(verifyBackupCode(codes[0], hashes)); + // Last element + assert.ok(verifyBackupCode(codes[2], hashes)); + }); + + it('returns false when none of the stored hashes match', () => { + const correct = generateBackupCodes(3); + const hashes = correct.map(hashBackupCode); + const wrong = 'ffff-ffff'; + assert.ok(!verifyBackupCode(wrong, hashes)); + }); + + it('returns false with an empty hash array', () => { + assert.ok(!verifyBackupCode('a3f8-1b2c', [])); + }); + + it('rejects malformed stored hash strings', () => { + const code = generateBackupCodes(1)[0]; + assert.ok(!verifyBackupCode(code, ['not-a-hash'])); + assert.ok(!verifyBackupCode(code, ['scrypt$', 'malformed'])); + }); +}); diff --git a/app/server/src/auth/backup-codes.ts b/app/server/src/auth/backup-codes.ts new file mode 100644 index 0000000..919a174 --- /dev/null +++ b/app/server/src/auth/backup-codes.ts @@ -0,0 +1,62 @@ +// Backup codes — scrypt-hash one-time-use recovery tokens (DESIGN.md auth seam). +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +/** + * Format a single backup code as 8 hex chars + dash (e.g. "a3f8-1b2c"). + */ +function formatCode(code: string): string { + return `${code.slice(0, 4)}-${code.slice(4)}`; +} + +/** + * Generate `n` random backup codes in the form "XXXX-XXXX" (each X is a lowercase hex digit). + * Each code is guaranteed unique within the returned array. + */ +export function generateBackupCodes(n = 10): string[] { + const codes: string[] = []; + for (let i = 0; i < n; i++) { + let attempt: string; + do { + attempt = formatCode(randomBytes(4).toString('hex')); // 8 hex chars → "XXXX-XXXX" + } while (codes.includes(attempt)); + codes.push(attempt); + } + return codes; +} + +/** + * Hash a single backup code using scrypt. + * Returns the same wire format as hashPassword: "scrypt$$". + */ +export function hashBackupCode(code: string): string { + const salt = randomBytes(16); + const hash = scryptSync(code, salt, 64); + return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; +} + +/** + * Constant-time verify against a set of stored hashes. Returns true if any match. + */ +export function verifyBackupCode(code: string, storedHashes: string[]): boolean { + let matched = false; + for (const stored of storedHashes) { + const parts = stored.split('$'); + if (parts.length !== 3 || parts[0] !== 'scrypt') continue; + try { + const salt = Buffer.from(parts[1], 'hex'); + const expected = Buffer.from(parts[2], 'hex'); + const computed = scryptSync(code, salt, 64); + if (computed.length === expected.length) { + if (timingSafeEqual(computed, expected)) matched = true; + } else { + // Constant-time: still touch both buffers to avoid length leakage. + timingSafeEqual(computed, Buffer.alloc(expected.length)); + timingSafeEqual(computed, expected); + } + } catch { + // Malformed hex — skip this entry (constant-time handled above). + continue; + } + } + return matched; +}