Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { prepareWebuiAuthStateForRestore } from '../src/webui/auth';
|
|
import {
|
|
beginTotpEnrollment,
|
|
confirmTotpEnrollment,
|
|
consumeRecoveryCode,
|
|
decideSecondFactorLogin,
|
|
generateRecoveryCodes,
|
|
hashRecoveryCode,
|
|
parseTotpStateLenient,
|
|
prepareTotpStateForRestore,
|
|
regenerateRecoveryCodes,
|
|
rewrapTotpSecret,
|
|
unwrapTotpSecret,
|
|
verifySecondFactor,
|
|
verifyTotpCode,
|
|
wrapTotpSecret,
|
|
} from '../src/webui/totp';
|
|
|
|
// RFC 6238 Appendix B, SHA-1, secret ASCII "12345678901234567890".
|
|
// 8-digit vector at T=59 is 94287082; 6-digit TOTP is that value mod 10^6.
|
|
const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
|
const T59_MS = 59_000;
|
|
const T59_CODE = '287082';
|
|
const T1111111109_MS = 1_111_111_109_000;
|
|
const T1111111109_CODE = '081804';
|
|
const PASSWORD = 'Correct-Horse-1!';
|
|
|
|
describe('verifyTotpCode', () => {
|
|
it('accepts the RFC 6238 SHA-1 6-digit code at T=59', () => {
|
|
expect(verifyTotpCode(RFC_SECRET, T59_CODE, T59_MS)).toEqual({ ok: true, step: 1 });
|
|
});
|
|
|
|
it('rejects a wrong 6-digit code at the same instant', () => {
|
|
expect(verifyTotpCode(RFC_SECRET, '000000', T59_MS)).toEqual({ ok: false });
|
|
});
|
|
|
|
it('rejects the same time-step being used twice', () => {
|
|
expect(verifyTotpCode(RFC_SECRET, T59_CODE, T59_MS, 1)).toEqual({ ok: false });
|
|
});
|
|
});
|
|
|
|
describe('wrapTotpSecret', () => {
|
|
it('round-trips a TOTP secret with the wrapping password', () => {
|
|
const wrapped = wrapTotpSecret(PASSWORD, RFC_SECRET);
|
|
expect(wrapped.ciphertext.toLowerCase()).not.toContain(
|
|
Buffer.from(RFC_SECRET, 'utf8').toString('hex'),
|
|
);
|
|
expect(unwrapTotpSecret(PASSWORD, wrapped)).toBe(RFC_SECRET);
|
|
});
|
|
|
|
it('returns null when the wrapping password is wrong', () => {
|
|
const wrapped = wrapTotpSecret(PASSWORD, RFC_SECRET);
|
|
expect(unwrapTotpSecret('Wrong-Password-2!', wrapped)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('recovery codes', () => {
|
|
it('generates 8 XXXX-XXXX codes that each verify once', () => {
|
|
const codes = generateRecoveryCodes();
|
|
expect(codes).toHaveLength(8);
|
|
expect(new Set(codes).size).toBe(8);
|
|
|
|
for (const code of codes) {
|
|
expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
|
|
}
|
|
|
|
const hashes = codes.map(hashRecoveryCode);
|
|
const first = consumeRecoveryCode(codes[0]!, hashes);
|
|
expect(first.ok).toBe(true);
|
|
|
|
if (!first.ok) {
|
|
return;
|
|
}
|
|
|
|
expect(first.remainingHashes).toHaveLength(7);
|
|
expect(consumeRecoveryCode(codes[0]!, first.remainingHashes)).toEqual({ ok: false });
|
|
});
|
|
});
|
|
|
|
describe('totp enrollment and second factor', () => {
|
|
it('enables 2FA only after a matching TOTP code and then accepts a later code', () => {
|
|
const enrollment = beginTotpEnrollment({
|
|
issuer: 'SnowLuma',
|
|
accountName: 'testhost',
|
|
secret: RFC_SECRET,
|
|
});
|
|
expect(enrollment.otpauthUrl).toContain('otpauth://totp/');
|
|
expect(enrollment.otpauthUrl).toContain('secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');
|
|
expect(enrollment.otpauthUrl).toContain('issuer=SnowLuma');
|
|
|
|
expect(() =>
|
|
confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: '000000',
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
}),
|
|
).toThrow(/验证码/);
|
|
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
expect(enabled.recoveryCodes).toHaveLength(8);
|
|
expect(enabled.state.lastUsedStep).toBe(1);
|
|
expect(enabled.state.label).toBe('SnowLuma (testhost)');
|
|
expect(enabled.state.recoveryCodeHashes).toHaveLength(8);
|
|
|
|
expect(
|
|
verifySecondFactor({
|
|
password: PASSWORD,
|
|
totp: T59_CODE,
|
|
state: enabled.state,
|
|
atMs: T59_MS,
|
|
}),
|
|
).toEqual({ ok: false });
|
|
|
|
const later = verifySecondFactor({
|
|
password: PASSWORD,
|
|
totp: T1111111109_CODE,
|
|
state: enabled.state,
|
|
atMs: T1111111109_MS,
|
|
});
|
|
expect(later.ok).toBe(true);
|
|
|
|
if (!later.ok) {
|
|
return;
|
|
}
|
|
|
|
expect(later.state.lastUsedStep).toBeGreaterThan(enabled.state.lastUsedStep);
|
|
});
|
|
|
|
it('accepts a recovery code at login and consumes only that hash', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
const used = enabled.recoveryCodes[3]!;
|
|
const result = verifySecondFactor({
|
|
password: PASSWORD,
|
|
recoveryCode: used,
|
|
state: enabled.state,
|
|
atMs: T59_MS,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
|
|
expect(result.state.recoveryCodeHashes).toHaveLength(7);
|
|
expect(
|
|
verifySecondFactor({
|
|
password: PASSWORD,
|
|
recoveryCode: used,
|
|
state: result.state,
|
|
atMs: T59_MS,
|
|
}),
|
|
).toEqual({ ok: false });
|
|
});
|
|
|
|
it('re-wraps the TOTP secret when the password changes', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
const nextPassword = 'Correct-Horse-2!';
|
|
const rewrapped = rewrapTotpSecret(PASSWORD, nextPassword, enabled.state);
|
|
expect(unwrapTotpSecret(nextPassword, rewrapped)).toBe(RFC_SECRET);
|
|
expect(unwrapTotpSecret(PASSWORD, rewrapped)).toBeNull();
|
|
expect(
|
|
verifySecondFactor({
|
|
password: nextPassword,
|
|
totp: T1111111109_CODE,
|
|
state: rewrapped,
|
|
atMs: T1111111109_MS,
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('totp persistence', () => {
|
|
it('round-trips a valid persisted totp object for backup restore', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
const restored = prepareTotpStateForRestore(JSON.parse(JSON.stringify(enabled.state)));
|
|
expect(restored).toEqual(enabled.state);
|
|
});
|
|
|
|
it('rejects a malformed totp object on restore', () => {
|
|
expect(() => prepareTotpStateForRestore({ wrapSalt: 'zz' })).toThrow(/totp/i);
|
|
});
|
|
|
|
it('treats a malformed totp object as absent when loading live config', () => {
|
|
expect(parseTotpStateLenient({ wrapSalt: 'zz' })).toBeUndefined();
|
|
expect(parseTotpStateLenient(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('keeps totp on credential restore and rejects a malformed totp object', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
const base = {
|
|
passwordHash: 'ab'.repeat(64),
|
|
passwordSalt: 'cd'.repeat(16),
|
|
mustChangePassword: false,
|
|
generatedAt: '2026-06-18T00:00:00.000Z',
|
|
updatedAt: '2026-06-18T00:00:00.000Z',
|
|
};
|
|
expect(prepareWebuiAuthStateForRestore({ ...base, totp: enabled.state }).totp).toEqual(
|
|
enabled.state,
|
|
);
|
|
expect(() =>
|
|
prepareWebuiAuthStateForRestore({ ...base, totp: { wrapSalt: 'zz' } }),
|
|
).toThrow(/totp/i);
|
|
});
|
|
});
|
|
|
|
describe('decideSecondFactorLogin', () => {
|
|
it('asks for TOTP when 2FA is on and the request has no second factor', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
expect(
|
|
decideSecondFactorLogin({
|
|
totpEnabled: true,
|
|
state: enabled.state,
|
|
password: PASSWORD,
|
|
atMs: T59_MS,
|
|
}),
|
|
).toEqual({ kind: 'needs-totp' });
|
|
});
|
|
|
|
it('skips the second factor when 2FA is off', () => {
|
|
expect(
|
|
decideSecondFactorLogin({
|
|
totpEnabled: false,
|
|
password: PASSWORD,
|
|
totp: '000000',
|
|
atMs: T59_MS,
|
|
}),
|
|
).toEqual({ kind: 'ok' });
|
|
});
|
|
});
|
|
|
|
describe('regenerateRecoveryCodes', () => {
|
|
it('replaces every recovery hash after a valid TOTP', () => {
|
|
const enabled = confirmTotpEnrollment({
|
|
password: PASSWORD,
|
|
secret: RFC_SECRET,
|
|
code: T59_CODE,
|
|
label: 'SnowLuma (testhost)',
|
|
atMs: T59_MS,
|
|
});
|
|
const oldCode = enabled.recoveryCodes[0]!;
|
|
const next = regenerateRecoveryCodes({
|
|
password: PASSWORD,
|
|
state: enabled.state,
|
|
totp: T1111111109_CODE,
|
|
atMs: T1111111109_MS,
|
|
});
|
|
expect(next.recoveryCodes).toHaveLength(8);
|
|
expect(next.recoveryCodes).not.toContain(oldCode);
|
|
expect(
|
|
verifySecondFactor({
|
|
password: PASSWORD,
|
|
recoveryCode: oldCode,
|
|
state: next.state,
|
|
atMs: T1111111109_MS,
|
|
}),
|
|
).toEqual({ ok: false });
|
|
expect(
|
|
verifySecondFactor({
|
|
password: PASSWORD,
|
|
recoveryCode: next.recoveryCodes[0]!,
|
|
state: next.state,
|
|
atMs: T1111111109_MS,
|
|
}).ok,
|
|
).toBe(true);
|
|
});
|
|
});
|