cdde937d7f
* fix(backend): reject a replayed TOTP code within its validity window verifyTotp() was stateless — otplib's window:1 tolerance meant the same 6-digit code could complete two independent logins inside its ~90s validity window. Track each admin's last-consumed step and reject a code that doesn't advance past it. * fix(backend): make the TOTP replay-tracking persist atomic verifyTotpEncryptedStep() read two_factor_last_used_step, then a plain UPDATE wrote the new step with no conditional guard — two concurrent requests carrying the same captured code could both pass the check before either UPDATE landed. The persist is now a conditional UPDATE (only advances the step, checked via affected-row count), so a losing concurrent request is correctly treated as a replay. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
/**
|
|
* Unit tests for mfaService — admin TOTP MFA (#738).
|
|
*
|
|
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
|
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
|
* derivation has key material (the service derives the AES key from
|
|
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
|
*/
|
|
|
|
// Must be set BEFORE the service is required — the key is derived lazily per
|
|
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
|
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
|
|
|
const { authenticator } = require('otplib');
|
|
const mfaService = require('../../src/services/mfaService');
|
|
|
|
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
|
it('round-trips encrypt → decrypt to the original secret', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const blob = mfaService.encryptSecret(secret);
|
|
expect(blob).toEqual(expect.any(String));
|
|
expect(blob).not.toContain(secret); // stored form is not plaintext
|
|
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
|
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
|
});
|
|
|
|
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const a = mfaService.encryptSecret(secret);
|
|
const b = mfaService.encryptSecret(secret);
|
|
expect(a).not.toBe(b);
|
|
expect(mfaService.decryptSecret(a)).toBe(secret);
|
|
expect(mfaService.decryptSecret(b)).toBe(secret);
|
|
});
|
|
|
|
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
|
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
|
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
|
});
|
|
|
|
it('throws when the auth tag / ciphertext is tampered with', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
|
// Flip a character in the ciphertext → GCM auth check must fail.
|
|
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
|
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('mfaService — TOTP verification', () => {
|
|
it('accepts a freshly generated code for the plaintext secret', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
|
});
|
|
|
|
it('tolerates whitespace in the submitted code', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
|
});
|
|
|
|
it('rejects a wrong code', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
const wrong = code === '000000' ? '111111' : '000000';
|
|
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty inputs rather than throwing', () => {
|
|
const secret = mfaService.generateSecret();
|
|
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
|
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
|
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
|
});
|
|
|
|
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const stored = mfaService.encryptSecret(secret);
|
|
const code = authenticator.generate(secret);
|
|
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
|
|
|
const wrong = code === '000000' ? '111111' : '000000';
|
|
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
|
});
|
|
|
|
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('mfaService — replay protection (GHSA-qcwx-r25m-j869)', () => {
|
|
it('verifyTotp accepts a code once and rejects the same code as a replay', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
|
|
// First use: no lastUsedStep yet, so it's accepted.
|
|
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
|
|
|
// Simulate persisting the matched step and replaying the same code: the
|
|
// matched step must strictly advance past lastUsedStep, so this fails.
|
|
const step = mfaService.currentTotpStep();
|
|
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
|
// A lastUsedStep the code hasn't caught up to yet also rejects it.
|
|
expect(mfaService.verifyTotp(code, secret, step + 1)).toBe(false);
|
|
});
|
|
|
|
it('verifyTotpEncryptedStep returns the matched step on success and null on replay', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const stored = mfaService.encryptSecret(secret);
|
|
const code = authenticator.generate(secret);
|
|
|
|
const step = mfaService.verifyTotpEncryptedStep(code, stored, null);
|
|
expect(step).toEqual(expect.any(Number));
|
|
expect(step).toBeGreaterThan(0);
|
|
|
|
// Replaying the same code against the just-persisted step is rejected.
|
|
expect(mfaService.verifyTotpEncryptedStep(code, stored, step)).toBeNull();
|
|
});
|
|
|
|
it('a freshly generated code for the next TOTP step is accepted after a replay is rejected', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const code = authenticator.generate(secret);
|
|
const step = mfaService.verifyTotpEncryptedStep(code, mfaService.encryptSecret(secret), null)
|
|
|| mfaService.currentTotpStep();
|
|
|
|
// Same-step replay: rejected.
|
|
expect(mfaService.verifyTotp(code, secret, step)).toBe(false);
|
|
|
|
// A code minted for the next step (via a cloned authenticator with a
|
|
// future epoch, not by mocking Date.now()) advances past last_used_step.
|
|
const nextStepAuthenticator = authenticator.clone({ epoch: Date.now() + 30000 });
|
|
const nextCode = nextStepAuthenticator.generate(secret);
|
|
expect(mfaService.verifyTotp(nextCode, secret, step)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('mfaService — otpauth URI / QR', () => {
|
|
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
|
const secret = mfaService.generateSecret();
|
|
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
|
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
|
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
|
expect(uri).toContain(`secret=${secret}`);
|
|
});
|
|
|
|
it('builds a PNG data-URL QR for the URI', async () => {
|
|
const secret = mfaService.generateSecret();
|
|
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
|
const qr = await mfaService.buildQrDataUrl(uri);
|
|
expect(qr).toMatch(/^data:image\/png;base64,/);
|
|
});
|
|
});
|
|
|
|
describe('mfaService — recovery codes', () => {
|
|
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
|
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
|
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
|
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
|
expect(new Set(plain).size).toBe(10);
|
|
expect(new Set(hashed).size).toBe(10);
|
|
// Hashes are bcrypt, not the plaintext.
|
|
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
|
plain.forEach((p) => expect(hashed).not.toContain(p));
|
|
});
|
|
|
|
it('formats a raw code into 4-char groups', () => {
|
|
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
|
});
|
|
|
|
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
|
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
|
const target = plain[3];
|
|
|
|
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
|
expect(first.matched).toBe(true);
|
|
expect(first.remainingHashes).toHaveLength(9);
|
|
|
|
// Reusing the same code against the reduced set must now fail.
|
|
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
|
expect(reuse.matched).toBe(false);
|
|
expect(reuse.remainingHashes).toHaveLength(9);
|
|
});
|
|
|
|
it('matches case-insensitively and trims whitespace', async () => {
|
|
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
|
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
|
expect(res.matched).toBe(true);
|
|
});
|
|
|
|
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
|
const { hashed } = await mfaService.generateRecoveryCodes();
|
|
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
|
expect(res.matched).toBe(false);
|
|
expect(res.remainingHashes).toHaveLength(10);
|
|
});
|
|
|
|
it('handles empty / missing input safely', async () => {
|
|
const { hashed } = await mfaService.generateRecoveryCodes();
|
|
const res = await mfaService.consumeRecoveryCode('', hashed);
|
|
expect(res.matched).toBe(false);
|
|
expect(res.remainingHashes).toBe(hashed);
|
|
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
|
expect(noHashes.matched).toBe(false);
|
|
expect(noHashes.remainingHashes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('mfaService — parseRecoveryCodes', () => {
|
|
it('parses a JSON string array', () => {
|
|
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
|
});
|
|
it('passes an already-array through', () => {
|
|
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
|
});
|
|
it('returns [] for null / garbage / non-array JSON', () => {
|
|
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
|
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
|
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('mfaService — isEnrolled coercion', () => {
|
|
it('treats true / 1 / "1" as enrolled', () => {
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
|
});
|
|
it('treats false / 0 / null / missing as not enrolled', () => {
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
|
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
|
expect(mfaService.isEnrolled({})).toBe(false);
|
|
expect(mfaService.isEnrolled(null)).toBe(false);
|
|
});
|
|
});
|