import { afterEach, describe, expect, it, vi } from 'vitest'; import { authenticator, createRecoveryCodes, decryptSecret, encryptSecret, recoveryIndex, totpCounter, } from '../../src/lib/auth-crypto'; import { loginContext } from '../../src/lib/login-context'; afterEach(() => vi.unstubAllEnvs()); describe('Admin two-factor primitives', () => { const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; it('matches RFC 6238 SHA1 vectors truncated to six digits', () => { expect(authenticator(secret).generate({ timestamp: 59000 })).toBe('287082'); expect(authenticator(secret).generate({ timestamp: 1111111109000 })).toBe('081804'); }); it('accepts only the adjacent time window and prevents replay', () => { const time = 1700000000000, counter = Math.floor(time / 30000); const code = authenticator(secret).generate({ timestamp: time }); expect(totpCounter(secret, code, -1, time)).toBe(counter); expect(totpCounter(secret, code, counter, time)).toBeNull(); expect( totpCounter(secret, authenticator(secret).generate({ timestamp: time - 90000 }), -1, time), ).toBeNull(); expect(totpCounter(secret, 'abcdef', -1, time)).toBeNull(); }); it('encrypts secrets with authenticated purpose binding and randomized IVs', () => { vi.stubEnv('AUTH_ENCRYPTION_KEY', 'ab'.repeat(32)); const encrypted = encryptSecret(secret, 'totp:admin'); expect(encrypted).not.toContain(secret); expect(encryptSecret(secret, 'totp:admin')).not.toBe(encrypted); expect(decryptSecret(encrypted, 'totp:admin')).toBe(secret); expect(() => decryptSecret(encrypted, 'smtp')).toThrow(); const parts = encrypted.split('.'); const data = Buffer.from(parts[2], 'base64url'); data[0] ^= 1; parts[2] = data.toString('base64url'); expect(() => decryptSecret(parts.join('.'), 'totp:admin')).toThrow(); }); it('requires a persistent 256-bit encryption key', () => { vi.stubEnv('AUTH_ENCRYPTION_KEY', ''); expect(() => encryptSecret(secret, 'test')).toThrow(/AUTH_ENCRYPTION_KEY/); }); it('generates independent recovery codes and checks their hashes', () => { const { codes, hashes } = createRecoveryCodes(); expect(new Set(codes).size).toBe(10); expect(recoveryIndex(hashes, codes[2].toLowerCase())).toBe(2); expect( recoveryIndex( hashes.filter((_, n) => n !== 2), codes[2], ), ).toBe(-1); expect(recoveryIndex(hashes, '000000')).toBe(-1); expect(hashes).not.toContain(codes[0]); }); }); describe('Reported login origin', () => { it('does not trust the client-controlled first forwarded address', () => { vi.stubEnv('TRUST_PROXY', 'false'); const context = loginContext( new Request('https://hamham.test', { headers: { 'x-forwarded-for': '1.2.3.4, 2001:db8::1', 'user-agent': 'Test browser' }, }), ); expect(context.ip).toBe('2001:db8::1'); expect(context.ipSource).toContain('nicht verifiziert'); expect( loginContext( new Request('https://hamham.test', { headers: { 'x-forwarded-for': 'not-an-ip' } }), ).ip, ).toBeNull(); }); });