import { Logger } from '@nestjs/common'; import { KEY_ID_PATTERN } from '../../common/crypto/key-ring'; import { decryptMfaSecret, encryptMfaSecret, mfaSecretNeedsRekey, } from './mfa-crypto.util'; // Written by the code before the key ring (AES key sha256(JWT_ACCESS_SECRET), // no key id) with JWT_ACCESS_SECRET = 'x' * 40. const LEGACY_SECRET = 'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ=='; const PLAINTEXT = 'JBSWY3DPEHPK3PXP'; describe('mfa-crypto.util', () => { const originalEnv = { ...process.env }; beforeEach(() => { process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40); delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS; jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); afterEach(() => { process.env = { ...originalEnv }; jest.restoreAllMocks(); }); it('round-trips a TOTP secret and prefixes the key id', () => { const ciphertext = encryptMfaSecret(PLAINTEXT); expect(ciphertext).toMatch(KEY_ID_PATTERN); expect(ciphertext).not.toContain(PLAINTEXT); expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); }); it('produces a different ciphertext every time (random IV)', () => { expect(encryptMfaSecret(PLAINTEXT)).not.toBe(encryptMfaSecret(PLAINTEXT)); }); it('no longer depends on JWT_ACCESS_SECRET', () => { const ciphertext = encryptMfaSecret(PLAINTEXT); process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); }); it('fails to decrypt with the wrong key', () => { const ciphertext = encryptMfaSecret(PLAINTEXT); process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40); expect(() => decryptMfaSecret(ciphertext)).toThrow('unknown key id'); }); it('opens a secret sealed with MFA_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => { const ciphertext = encryptMfaSecret(PLAINTEXT); process.env.MFA_ENCRYPTION_KEY_PREVIOUS = process.env.MFA_ENCRYPTION_KEY; process.env.MFA_ENCRYPTION_KEY = 'n'.repeat(40); expect(decryptMfaSecret(ciphertext)).toBe(PLAINTEXT); expect(mfaSecretNeedsRekey(ciphertext)).toBe(true); }); it('does not flag a secret sealed with the current key', () => { expect(mfaSecretNeedsRekey(encryptMfaSecret(PLAINTEXT))).toBe(false); }); it('still opens a legacy MFA secret encrypted with the JWT-derived key', () => { expect(decryptMfaSecret(LEGACY_SECRET)).toBe(PLAINTEXT); expect(mfaSecretNeedsRekey(LEGACY_SECRET)).toBe(true); }); it('fails closed on a legacy secret once JWT_ACCESS_SECRET changed', () => { process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); expect(() => decryptMfaSecret(LEGACY_SECRET)).toThrow( 'ciphertext failed authentication', ); }); it('never retries a k…: value that fails authentication with the legacy key', () => { const warn = jest.spyOn(Logger.prototype, 'warn'); const currentKeyId = encryptMfaSecret(PLAINTEXT).split(':')[0]; // A valid legacy ciphertext behind the current key id: only a retry with // sha256(JWT_ACCESS_SECRET) could open it. const disguised = `${currentKeyId}:${LEGACY_SECRET}`; expect(() => decryptMfaSecret(disguised)).toThrow( 'ciphertext failed authentication', ); expect(() => decryptMfaSecret(`k00000000:${LEGACY_SECRET}`)).toThrow( 'unknown key id', ); expect(warn).not.toHaveBeenCalled(); }); it('fails on a tampered ciphertext', () => { const [keyId, body] = encryptMfaSecret(PLAINTEXT).split(':'); const [iv, tag, data] = body.split('.'); const bytes = Buffer.from(data, 'base64'); bytes[0] ^= 0xff; expect(() => decryptMfaSecret(`${keyId}:${iv}.${tag}.${bytes.toString('base64')}`), ).toThrow('ciphertext failed authentication'); }); it('rejects a malformed ciphertext', () => { expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow( 'malformed ciphertext', ); }); });