import { describe, it, expect } from 'vitest' import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret, validateTotpSecret } from '../../../src/lib/crypto/totp.js' // RFC 6238 test vectors (Appendix B, SHA-1) use the ASCII secret // "12345678901234567890" whose base32 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ. const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' describe('base32Decode', () => { it('decodes the RFC secret to the expected ASCII bytes', () => { const bytes = base32Decode(RFC_SECRET) expect(String.fromCharCode(...bytes)).toBe('12345678901234567890') }) it('ignores whitespace and padding', () => { expect(base32Decode('JBSWY3DP EHPK3PXP').length).toBe(10) expect(base32Decode('JBSWY3DPEHPK3PXP==').length).toBe(10) }) it('throws on invalid characters', () => { // '0','1','8','9' are not valid base32 expect(() => base32Decode('ABC0')).toThrow(/Invalid base32/) }) }) describe('extractSecret', () => { it('passes through a bare base32 secret (normalized)', () => { expect(extractSecret('jbs-wy3dpehpk3pxp')).toBe('JBSWY3DPEHPK3PXP') }) it('pulls the secret out of an otpauth:// URI', () => { expect(extractSecret('otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example')) .toBe('JBSWY3DPEHPK3PXP') }) it('returns empty string for empty input', () => { expect(extractSecret('')).toBe('') expect(extractSecret(' ')).toBe('') }) }) describe('generateTotp (RFC 6238 vectors)', () => { it('matches the RFC 6238 SHA-1 vectors at 6 digits', async () => { const vectors = [ [59, '287082'], [1111111109, '081804'], [1111111111, '050471'], [1234567890, '005924'], [2000000000, '279037'], [20000000000, '353130'], ] for (const [t, expected] of vectors) { await expect(generateTotp(RFC_SECRET, { timestamp: t })).resolves.toBe(expected) } }) it('supports custom digit counts', async () => { // 8-digit vector at t=59 is 94287082 await expect(generateTotp(RFC_SECRET, { timestamp: 59, digits: 8 })).resolves.toBe('94287082') }) it('rejects an empty/invalid secret', async () => { await expect(generateTotp('')).rejects.toThrow(/empty or invalid/) await expect(generateTotp('!!!!')).rejects.toThrow() }) it('changes over time', async () => { const a = await generateTotp(RFC_SECRET, { timestamp: 30 }) const b = await generateTotp(RFC_SECRET, { timestamp: 90 }) // 30 and 90 map to counters 1 and 3 — codes differ. expect(a).not.toBe(b) }) }) describe('validateTotpSecret', () => { const GOOD = 'JBSWY3DPEHPK3PXP' // valid 10-byte base32 secret it('accepts a blank secret (optional)', () => { expect(validateTotpSecret('').valid).toBe(true) expect(validateTotpSecret(' ').valid).toBe(true) }) it('accepts a valid bare base32 secret', () => { expect(validateTotpSecret(GOOD).valid).toBe(true) }) it('accepts hyphen-grouped base32 and otpauth:// URIs', () => { expect(validateTotpSecret('JBSW-Y3DP-EHPK-3PXP').valid).toBe(true) expect(validateTotpSecret(`otpauth://totp/Example:alice?secret=${GOOD}&issuer=Example`).valid).toBe(true) }) it('rejects invalid base32 characters', () => { const r = validateTotpSecret('ABC9012345678901') expect(r.valid).toBe(false) expect(r.error).toMatch(/Invalid TOTP secret/) }) it('rejects a secret that is too short', () => { const r = validateTotpSecret('ABCDEF') // 4 decoded bytes < minBytes expect(r.valid).toBe(false) expect(r.error).toMatch(/too short/) }) it('rejects an otpauth:// URI with no secret parameter', () => { const r = validateTotpSecret('otpauth://totp/x?issuer=y') expect(r.valid).toBe(false) }) }) describe('totpRemainingSeconds', () => { it('returns period for exact boundary', () => { expect(totpRemainingSeconds({ timestamp: 0, period: 30 })).toBe(30) }) it('counts down within a period', () => { expect(totpRemainingSeconds({ timestamp: 5, period: 30 })).toBe(25) expect(totpRemainingSeconds({ timestamp: 29, period: 30 })).toBe(1) }) })