- New src/lib/crypto/totp.js: native RFC 6238 TOTP using Web Crypto HMAC-SHA1 (no external crypto). base32Decode, extractSecret (bare base32 or otpauth:// URI), generateTotp, totpRemainingSeconds. - Entries gain an optional encryptedTotpSecret, stored AES-GCM-encrypted like passwords. schema.js createEntry/updateEntry/docs updated. - Export/import re-key the TOTP secret alongside passwords when sealing with a separate password, and decrypt/re-encrypt on import so TOTP survives moves. - EntryForm: optional 'TOTP Secret (2FA)' field (base32 or otpauth:// URI). - EntryDetail: live 6-digit TOTP display updating every second with a countdown and urgency indicator, plus copy; guarded cleanup timer on unmount. - Tests: RFC 6238 SHA-1 vectors (6 & 8 digit), base32/extractSecret, remaining seconds, schema round-trip. 157 total pass.
83 lines
2.8 KiB
JavaScript
83 lines
2.8 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret } 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('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)
|
|
})
|
|
})
|