SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,305 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user