slice 2a (ornith): backup codes (scrypt, constant-time verify) + 13 tests

This commit is contained in:
Investor Flow Build
2026-06-29 19:19:59 -04:00
parent 3b6d306e4c
commit 9df05ade71
2 changed files with 162 additions and 0 deletions
@@ -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$<hex-salt>$<hex-hash> 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<string>();
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']));
});
});
+62
View File
@@ -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$<hex-salt>$<hex-hash>".
*/
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;
}