114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import {
|
|
base32Decode,
|
|
base32Encode,
|
|
generateTotpSecret,
|
|
hashPassword,
|
|
hotp,
|
|
InMemorySessionRepo,
|
|
totp,
|
|
totpUri,
|
|
verifyPassword,
|
|
verifyTotp,
|
|
} from '../src/accounts';
|
|
|
|
describe('password hashing (scrypt)', () => {
|
|
it('round-trips a correct password', () => {
|
|
const stored = hashPassword('correct horse battery staple');
|
|
expect(verifyPassword('correct horse battery staple', stored)).toBe(true);
|
|
});
|
|
|
|
it('rejects a wrong password', () => {
|
|
const stored = hashPassword('correct horse battery staple');
|
|
expect(verifyPassword('wrong', stored)).toBe(false);
|
|
});
|
|
|
|
it('uses a random salt per hash', () => {
|
|
expect(hashPassword('same')).not.toBe(hashPassword('same'));
|
|
});
|
|
|
|
it('rejects malformed stored hashes', () => {
|
|
expect(verifyPassword('x', 'not-a-hash')).toBe(false);
|
|
expect(verifyPassword('x', '')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('base32', () => {
|
|
it('round-trips bytes', () => {
|
|
const buf = Buffer.from('hello world, this is base32');
|
|
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
|
|
});
|
|
|
|
it('encodes without padding', () => {
|
|
expect(base32Encode(Buffer.from('f'))).toBe('MY');
|
|
expect(base32Encode(Buffer.from('fo'))).toBe('MZXQ');
|
|
});
|
|
});
|
|
|
|
describe('TOTP (RFC 6238)', () => {
|
|
// RFC 6238 SHA-1 seed, ASCII "12345678901234567890", base32-encoded.
|
|
const RFC_SEED_B32 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
|
|
|
it.each([
|
|
[59_000, '287082'],
|
|
[1_111_111_109_000, '081804'],
|
|
[1_234_567_890_000, '005924'],
|
|
])('produces the RFC 6-digit truncation at %ims', (atMs, expected) => {
|
|
expect(totp(RFC_SEED_B32, atMs)).toBe(expected);
|
|
});
|
|
|
|
it('hotp pads short codes to 6 digits', () => {
|
|
expect(hotp(RFC_SEED_B32, Math.floor(1_234_567_890 / 30))).toBe('005924');
|
|
});
|
|
|
|
it('verifyTotp accepts codes within the window and rejects outside', () => {
|
|
const at = 1_111_111_109_000;
|
|
const code = totp(RFC_SEED_B32, at);
|
|
expect(verifyTotp(RFC_SEED_B32, code, at)).toBe(true);
|
|
expect(verifyTotp(RFC_SEED_B32, code, at + 30_000)).toBe(true); // one step later, window 1
|
|
expect(verifyTotp(RFC_SEED_B32, code, at + 90_000)).toBe(false); // three steps later
|
|
});
|
|
|
|
it('verifyTotp rejects malformed codes', () => {
|
|
expect(verifyTotp(RFC_SEED_B32, '12345', 59_000)).toBe(false);
|
|
expect(verifyTotp(RFC_SEED_B32, 'abcdef', 59_000)).toBe(false);
|
|
});
|
|
|
|
it('generateTotpSecret returns a decodable 160-bit base32 secret', () => {
|
|
const secret = generateTotpSecret();
|
|
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
|
|
expect(base32Decode(secret).length).toBe(20);
|
|
});
|
|
|
|
it('totpUri builds an otpauth URI', () => {
|
|
const uri = totpUri('ABC234', 'ada@example.com', 'Zappier');
|
|
expect(uri).toBe(
|
|
'otpauth://totp/Zappier:ada%40example.com?secret=ABC234&issuer=Zappier',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('InMemorySessionRepo', () => {
|
|
it('creates and retrieves sessions within the TTL', () => {
|
|
const repo = new InMemorySessionRepo();
|
|
const s = repo.create('cust_1', 60_000);
|
|
expect(s.customerId).toBe('cust_1');
|
|
expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1');
|
|
});
|
|
|
|
it('expires sessions after the TTL', () => {
|
|
const repo = new InMemorySessionRepo();
|
|
const s = repo.create('cust_1', 60_000);
|
|
expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined();
|
|
});
|
|
|
|
it('deletes sessions (logout)', () => {
|
|
const repo = new InMemorySessionRepo();
|
|
const s = repo.create('cust_1', 60_000);
|
|
repo.delete(s.token);
|
|
expect(repo.get(s.token, s.createdMs)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for unknown tokens', () => {
|
|
expect(new InMemorySessionRepo().get('nope')).toBeUndefined();
|
|
});
|
|
});
|