hermes-explorigin ae23b47a92 Validate TOTP secret in the edit dialog
- totp.js: add validateTotpSecret(input, { minBytes = 10 }). Secret is
  optional (blank ok); when provided it must decode to a base32 key of at
  least 80 bits, rejecting bad characters, typos, wrong format, and
  too-short secrets.
- EntryForm: validate the TOTP secret on submit; if invalid, show an inline
  error (⚠) and a red input border and block saving. Error clears as the
  user types.
- Tests: blank ok, valid bare/grouped/otpauth URIs, invalid chars, too-short,
  URI without a secret. 163 total pass.
2026-08-27 01:29:33 +00:00

118 lines
4.0 KiB
JavaScript

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