Files
SnowLuma/packages/core/tests/webui-totp.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

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