import { Logger } from '@nestjs/common'; import { encryptMfaSecret } from '../auth/mfa/mfa-crypto.util'; import { KEY_ID_PATTERN } from '../common/crypto/key-ring'; import { openWebhookSecret, sealWebhookSecret, webhookSecretNeedsRekey, } from './webhook-secret'; // Written by the code before the key ring: "enc:v1:" + AES-256-GCM with // sha256(JWT_ACCESS_SECRET), JWT_ACCESS_SECRET = 'x' * 40. const LEGACY_SEALED = 'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ'; // Rows from before webhook secrets were encrypted at all. const LEGACY_PLAIN = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64'; describe('webhook-secret', () => { const originalEnv = { ...process.env }; beforeEach(() => { process.env.JWT_ACCESS_SECRET = 'x'.repeat(40); process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40); delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS; delete process.env.MFA_ENCRYPTION_KEY; jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); afterEach(() => { process.env = { ...originalEnv }; jest.restoreAllMocks(); }); it('seals with the webhook key and a key id', () => { const stored = sealWebhookSecret('topsecret'); expect(stored).toMatch(KEY_ID_PATTERN); expect(stored).not.toContain('topsecret'); expect(openWebhookSecret(stored)).toBe('topsecret'); expect(webhookSecretNeedsRekey(stored)).toBe(false); }); it('opens legacy enc:v1 and plaintext webhook secrets and flags them for rekey', () => { expect(openWebhookSecret(LEGACY_SEALED)).toBe('topsecret'); expect(openWebhookSecret(LEGACY_PLAIN)).toBe(LEGACY_PLAIN); expect(webhookSecretNeedsRekey(LEGACY_SEALED)).toBe(true); expect(webhookSecretNeedsRekey(LEGACY_PLAIN)).toBe(true); }); it('opens a secret sealed with WEBHOOK_ENCRYPTION_KEY_PREVIOUS and flags it for rekey', () => { const stored = sealWebhookSecret('topsecret'); process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = process.env.WEBHOOK_ENCRYPTION_KEY; process.env.WEBHOOK_ENCRYPTION_KEY = 'v'.repeat(40); expect(openWebhookSecret(stored)).toBe('topsecret'); expect(webhookSecretNeedsRekey(stored)).toBe(true); }); it('fails closed on a legacy enc:v1 secret once JWT_ACCESS_SECRET changed', () => { process.env.JWT_ACCESS_SECRET = 'z'.repeat(40); expect(() => openWebhookSecret(LEGACY_SEALED)).toThrow( 'ciphertext failed authentication', ); }); it('never retries a k…: value that fails authentication as legacy or plain secret', () => { const warn = jest.spyOn(Logger.prototype, 'warn'); const currentKeyId = sealWebhookSecret('topsecret').split(':')[0]; // A valid legacy ciphertext behind the current key id: only a retry with // sha256(JWT_ACCESS_SECRET) could open it, and only the plaintext // fallback would hand the stored string back. const legacyPayload = LEGACY_SEALED.slice('enc:v1:'.length); expect(() => openWebhookSecret(`${currentKeyId}:${legacyPayload}`)).toThrow( 'ciphertext failed authentication', ); expect(() => openWebhookSecret(`k00000000:${legacyPayload}`)).toThrow( 'unknown key id', ); expect(warn).not.toHaveBeenCalled(); }); it('keeps MFA and webhook keys apart, even on the shared dev fallback', () => { delete process.env.WEBHOOK_ENCRYPTION_KEY; expect(() => openWebhookSecret(encryptMfaSecret('topsecret'))).toThrow( 'unknown key id', ); }); });