hermes-explorigin 9758e80a02 Add TOTP (2FA) support to entries
- 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.
2026-08-27 01:11:48 +00:00

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)
})
})