- 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.
118 lines
4.0 KiB
JavaScript
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)
|
|
})
|
|
})
|