diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index a5210991ee6b236ae496f34ab2a5d285d0699fa0..d1795dff51676cbd6d554df8f5f5d53567cb162d 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -57,3 +57,18 @@ export const FULL_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss.l Z`; export const SHORT_DATE_TIME_FORMAT = `mmm dd yyyy HH:MM:ss Z`; export const ISSUE_PATH_ID_SEPARATOR = '#'; + +export const MESSAGE_TYPES = { + // eslint-disable-next-line @gitlab/require-i18n-strings + AUTH_STATUS: 'O11Y_AUTH_STATUS', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN: 'O11Y_JWT_LOGIN', + // eslint-disable-next-line @gitlab/require-i18n-strings + JWT_LOGIN_ENCRYPTED: 'O11Y_JWT_LOGIN_ENCRYPTED', +}; +export const TIMEOUTS = { + MAX_MESSAGE_AGE: 1 * 60 * 1000, + MAX_CLOCK_SKEW: 30 * 1000, + BASE_RETRY_DELAY: 2000, + CLEANUP_INTERVAL: 60 * 1000, +}; diff --git a/app/assets/javascripts/observability/utils/crypto.js b/app/assets/javascripts/observability/utils/crypto.js new file mode 100644 index 0000000000000000000000000000000000000000..f33e1df7ffe2a25fc9c22e66f5496d5cdb4a0fb9 --- /dev/null +++ b/app/assets/javascripts/observability/utils/crypto.js @@ -0,0 +1,165 @@ +import { s__ } from '~/locale'; + +/** + * Secure Cross-Frame Messaging Encryption Utilities + * + * This module provides encryption/decryption for sensitive data passed between + * Vue parent and React iframe using Web Crypto API with AES-GCM encryption. + */ + +const CRYPTO_ERROR_MESSAGE = s__('Observability|Crypto operation failed'); +const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000; +const MIN_ENCRYPTION_KEY_LENGTH = 32; + +const ENCRYPTION_CONFIG = { + algorithm: 'AES-GCM', + keyLength: 256, + ivLength: 12, + tagLength: 128, + keyDerivationIterations: 100000, + saltLength: 16, + // eslint-disable-next-line @gitlab/require-i18n-strings -- Algorithm name + keyDerivationAlgorithm: 'PBKDF2', + hashAlgorithm: 'SHA-256', +}; + +const getSharedSecret = (encryptionKey) => { + if (!encryptionKey || encryptionKey.length < MIN_ENCRYPTION_KEY_LENGTH) { + throw new Error(s__('Observability|Valid encryption key is required for secure operation')); + } + + return new TextEncoder().encode(encryptionKey); +}; + +const deriveKey = async (secret, salt) => { + try { + const baseKey = await crypto.subtle.importKey( + 'raw', + secret, + ENCRYPTION_CONFIG.keyDerivationAlgorithm, + false, + ['deriveKey'], + ); + + return await crypto.subtle.deriveKey( + { + name: ENCRYPTION_CONFIG.keyDerivationAlgorithm, + salt, + iterations: ENCRYPTION_CONFIG.keyDerivationIterations, + hash: ENCRYPTION_CONFIG.hashAlgorithm, + }, + baseKey, + { + name: ENCRYPTION_CONFIG.algorithm, + length: ENCRYPTION_CONFIG.keyLength, + }, + false, + ['encrypt', 'decrypt'], + ); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const encryptPayload = async (data, origin, encryptionKey) => { + try { + const salt = window.crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.saltLength)); + const iv = window.crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.ivLength)); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, salt); + + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + + const additionalData = new TextEncoder().encode(origin); + const encrypted = await crypto.subtle.encrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + plaintext, + ); + + return { + encrypted: Array.from(new Uint8Array(encrypted)), + salt: Array.from(salt), + iv: Array.from(iv), + algorithm: ENCRYPTION_CONFIG.algorithm, + timestamp: Date.now(), + }; + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const decryptPayload = async (encryptedPayload, origin, encryptionKey) => { + try { + const { encrypted, salt, iv, algorithm, timestamp } = encryptedPayload; + + if (algorithm !== ENCRYPTION_CONFIG.algorithm) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + if (Date.now() - timestamp > MAX_MESSAGE_AGE_MS) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } + + const encryptedData = new Uint8Array(encrypted); + const saltBytes = new Uint8Array(salt); + const ivBytes = new Uint8Array(iv); + + const sharedSecret = getSharedSecret(encryptionKey); + const key = await deriveKey(sharedSecret, saltBytes); + + const additionalData = new TextEncoder().encode(origin); + + const decrypted = await crypto.subtle.decrypt( + { + name: ENCRYPTION_CONFIG.algorithm, + iv: ivBytes, + additionalData, + tagLength: ENCRYPTION_CONFIG.tagLength, + }, + key, + encryptedData, + ); + + const plaintext = new TextDecoder().decode(decrypted); + return JSON.parse(plaintext); + } catch (error) { + throw new Error(CRYPTO_ERROR_MESSAGE); + } +}; + +export const isCryptoSupported = () => { + try { + return Boolean( + window.crypto && + window.crypto.subtle && + window.crypto.getRandomValues && + typeof window.crypto.subtle.encrypt === 'function' && + typeof window.crypto.subtle.decrypt === 'function' && + typeof window.crypto.subtle.deriveKey === 'function', + ); + } catch { + return false; + } +}; + +export const generateSecureRandom = (length = 32) => { + if (length <= 0 || !Number.isInteger(length)) { + throw new Error(s__('Observability|Length must be a positive integer')); + } + if (!isCryptoSupported()) { + throw new Error(s__('Observability|Crypto API not supported')); + } + + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); +}; + +export const generateNonce = () => generateSecureRandom(16); diff --git a/app/assets/javascripts/observability/utils/message_validator.js b/app/assets/javascripts/observability/utils/message_validator.js new file mode 100644 index 0000000000000000000000000000000000000000..bf9cb6123925f67a602cc1e06c885f5a046558cf --- /dev/null +++ b/app/assets/javascripts/observability/utils/message_validator.js @@ -0,0 +1,192 @@ +import { s__ } from '~/locale'; + +import { MESSAGE_TYPES, TIMEOUTS } from '../constants'; + +export class MessageValidator { + constructor( + allowedOrigin, + allowedMessageType, + { + maxMessageAge = TIMEOUTS.MAX_MESSAGE_AGE, + maxClockSkew = TIMEOUTS.MAX_CLOCK_SKEW, + maxNonceHistory = 1000, + timestampTolerance = 5000, + } = {}, + ) { + if (!allowedOrigin || typeof allowedOrigin !== 'string') { + throw new Error(s__('Observability|allowedOrigin must be a non-empty string')); + } + if (!allowedMessageType || typeof allowedMessageType !== 'string') { + throw new Error(s__('Observability|allowedMessageType must be a non-empty string')); + } + + this.allowedOrigin = allowedOrigin; + this.allowedMessageType = allowedMessageType; + this.maxMessageAge = maxMessageAge; + this.maxClockSkew = maxClockSkew; + this.maxNonceHistory = maxNonceHistory; + this.timestampTolerance = timestampTolerance; + this.lastMessageTimestamp = 0; + this.processedNonces = new Set(); + this.recentTimestamps = new Set(); + this.lastCleanup = 0; + } + + validateMessage(event, expectedNonce, expectedCounter) { + const { origin, data } = event; + + if ( + !this.validateOrigin(origin) || + !this.validateStructure(data) || + !this.validateType(data.type) || + !this.validateNonce(data.nonce, expectedNonce) || + !this.validateCounter(data.counter, expectedCounter) || + !this.validateTimestamp(data.timestamp) + ) { + return { valid: false, error: s__('Observability|Message validation failed') }; + } + + this.trackNonce(data.nonce); + return { valid: true }; + } + + validateOrigin(origin) { + return origin === this.allowedOrigin; + } + + // eslint-disable-next-line class-methods-use-this + validateStructure(data) { + if (!data || typeof data !== 'object') { + return false; + } + + if ( + typeof data.type !== 'string' || + typeof data.timestamp !== 'number' || + typeof data.nonce !== 'string' || + typeof data.counter !== 'number' + ) { + return false; + } + + if (data.nonce.length === 0) { + return false; + } + + if (data.counter < 0) { + return false; + } + + return true; + } + + validateType(type) { + return type === this.allowedMessageType; + } + + validateNonce(nonce, expectedNonce) { + if (nonce !== expectedNonce) { + return false; + } + + if (this.processedNonces.has(nonce)) { + return false; + } + + return true; + } + + // eslint-disable-next-line class-methods-use-this + validateCounter(counter, expectedCounter) { + return counter === expectedCounter; + } + + validateTimestamp(timestamp) { + const now = Date.now(); + const messageAge = now - timestamp; + + if (messageAge > this.maxMessageAge) { + return false; + } + + if (messageAge < -this.maxClockSkew) { + return false; + } + + if (this.recentTimestamps.has(timestamp)) { + return false; + } + + if (this.lastMessageTimestamp === 0) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + const toleranceWindow = this.timestampTolerance; + const isWithinTolerance = Math.abs(timestamp - this.lastMessageTimestamp) <= toleranceWindow; + + if (isWithinTolerance) { + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + } + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + if (timestamp > this.lastMessageTimestamp) { + this.lastMessageTimestamp = timestamp; + this.recentTimestamps.add(timestamp); + this.cleanupOldTimestamps(now); + return true; + } + + return false; + } + + cleanupOldTimestamps(now) { + const cutoffTime = now - this.maxMessageAge; + if (!this.lastCleanup || now - this.lastCleanup > TIMEOUTS.CLEANUP_INTERVAL) { + for (const timestamp of this.recentTimestamps) { + if (timestamp < cutoffTime) { + this.recentTimestamps.delete(timestamp); + } + } + this.lastCleanup = now; + } + } + + trackNonce(nonce) { + this.processedNonces.add(nonce); + + if (this.processedNonces.size > this.maxNonceHistory) { + const noncesArray = Array.from(this.processedNonces); + const noncesToDelete = noncesArray.slice(0, 100); + noncesToDelete.forEach((n) => this.processedNonces.delete(n)); + } + } + + reset() { + this.lastMessageTimestamp = 0; + this.processedNonces.clear(); + this.recentTimestamps.clear(); + this.lastCleanup = 0; + } + + getValidationStats() { + return { + lastMessageTimestamp: this.lastMessageTimestamp, + processedNoncesCount: this.processedNonces.size, + recentTimestampsCount: this.recentTimestamps.size, + allowedOrigin: this.allowedOrigin, + allowedMessageType: this.allowedMessageType, + timestampTolerance: this.timestampTolerance, + }; + } +} + +export const createMessageValidator = (allowedOrigin, options = {}) => { + return new MessageValidator(allowedOrigin, MESSAGE_TYPES.AUTH_STATUS, options); +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 70b2265af9e8e812151c38860e2fe29a8ecc15d2..8cf307c2761a15d0cafdc72a7730c437d00d17a9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43082,6 +43082,12 @@ msgstr "" msgid "Observability|Creates alerts automatically for Observability-related errors." msgstr "" +msgid "Observability|Crypto API not supported" +msgstr "" + +msgid "Observability|Crypto operation failed" +msgstr "" + msgid "Observability|Danger Zone" msgstr "" @@ -43145,6 +43151,12 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Length must be a positive integer" +msgstr "" + +msgid "Observability|Message validation failed" +msgstr "" + msgid "Observability|O11y Service Settings" msgstr "" @@ -43196,9 +43208,18 @@ msgstr "" msgid "Observability|User Email" msgstr "" +msgid "Observability|Valid encryption key is required for secure operation" +msgstr "" + msgid "Observability|View our %{documentation} for further instructions on how to use these features." msgstr "" +msgid "Observability|allowedMessageType must be a non-empty string" +msgstr "" + +msgid "Observability|allowedOrigin must be a non-empty string" +msgstr "" + msgid "Oct" msgstr "" diff --git a/spec/frontend/observability/utils/crypto_spec.js b/spec/frontend/observability/utils/crypto_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..911836e17f7aa8dab47b149ebf70697e129211b2 --- /dev/null +++ b/spec/frontend/observability/utils/crypto_spec.js @@ -0,0 +1,329 @@ +/** + * Test suite for cross-frame authentication encryption + * + * These tests validate the security implementation across both Vue and React + * applications, ensuring encryption/decryption works correctly and securely. + */ + +import { + encryptPayload, + decryptPayload, + isCryptoSupported, + generateSecureRandom, + generateNonce, +} from '../../../../app/assets/javascripts/observability/utils/crypto'; + +describe('Cross-Frame Authentication Encryption', () => { + const fixtures = { + origin: 'https://test-origin.com', + encryptionKey: 'test-encryption-key-for-unit-tests-only', + payload: { + userId: 'test-user-123', + accessJwt: 'fake.jwt.token', + refreshJwt: 'fake.refresh.jwt.refresh', + theme: 'dark', + nonce: 'test-nonce-123456789abcdef', + timestamp: Date.now(), + counter: 1, + }, + }; + + const testHelpers = { + async setupCrypto() { + if (!global.crypto) { + const { webcrypto } = await import('crypto'); + global.crypto = webcrypto; + } + if (!global.crypto.subtle) { + const { webcrypto } = await import('crypto'); + global.crypto.subtle = webcrypto.subtle; + } + }, + + withoutCrypto(testFn) { + const originalCrypto = global.crypto; + const originalSubtle = global.crypto?.subtle; + + return () => { + delete global.crypto; + + try { + return testFn(); + } finally { + global.crypto = originalCrypto; + if (originalCrypto && originalSubtle) { + global.crypto.subtle = originalSubtle; + } + } + }; + }, + + encryptWithFixtures(payload = fixtures.payload) { + return encryptPayload(payload, fixtures.origin, fixtures.encryptionKey); + }, + + decryptWithFixtures(encrypted) { + return decryptPayload(encrypted, fixtures.origin, fixtures.encryptionKey); + }, + }; + + beforeAll(testHelpers.setupCrypto); + + describe('Crypto Feature Detection', () => { + it('detects crypto API support', () => { + expect(isCryptoSupported()).toBe(true); + }); + + it( + 'handles missing crypto API gracefully', + testHelpers.withoutCrypto(() => { + expect(isCryptoSupported()).toBe(false); + }), + ); + + it('generates nonce when crypto is supported', () => { + const nonce = generateNonce(); + expect(nonce).toMatch(/^[a-f0-9]{32}$/); + }); + + it( + 'throws error when crypto is not supported', + testHelpers.withoutCrypto(() => { + expect(() => generateNonce()).toThrow('Crypto API not supported'); + }), + ); + }); + + describe('Encryption/Decryption Workflow', () => { + it('encrypts and decrypts payload successfully', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(fixtures.payload); + }); + + it('fails decryption with wrong origin', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + + await expect( + decryptPayload(encrypted, 'https://wrong-origin.com', fixtures.encryptionKey), + ).rejects.toThrow('Crypto operation failed'); + }); + + it('fails decryption with tampered data', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.encrypted[0] = (encrypted.encrypted[0] + 1) % 256; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + + it('fails decryption with old timestamp', async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + encrypted.timestamp = Date.now() - 10 * 60 * 1000; + + await expect(testHelpers.decryptWithFixtures(encrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + + describe('Security Validations', () => { + it('generates unique nonces', () => { + const nonce1 = generateSecureRandom(32); + const nonce2 = generateSecureRandom(32); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toHaveLength(64); + expect(nonce2).toHaveLength(64); + }); + + describe('generateSecureRandom validation', () => { + const validInputs = [1, 32, 100, undefined]; + const invalidInputs = [ + [0, 'Length must be a positive integer'], + [-1, 'Length must be a positive integer'], + [1.5, 'Length must be a positive integer'], + ['32', 'Length must be a positive integer'], + [NaN, 'Length must be a positive integer'], + ]; + + validInputs.forEach((input) => { + it(`accepts valid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).not.toThrow(); + }); + }); + + invalidInputs.forEach(([input, expectedError]) => { + it(`rejects invalid input: ${input}`, () => { + expect(() => generateSecureRandom(input)).toThrow(expectedError); + }); + }); + }); + + const payloadTestCases = [ + { + name: 'small payload', + payload: { test: 'small' }, + }, + { + name: 'large payload', + payload: { + test: 'large', + data: 'x'.repeat(10000), + nested: { + deep: { + structure: { + with: ['arrays', 'and', 'objects'], + numbers: [1, 2, 3, 4, 5], + }, + }, + }, + }, + }, + { + name: 'unicode payload', + payload: { + emoji: '🔐🛡️🔑', + chinese: '加密数据', + arabic: 'البيانات المشفرة', + special: '!@#$%^&*()_+-=[]{}|;:,.<>?', + quotes: '"\'`', + }, + }, + { + name: 'empty payload', + payload: {}, + }, + ]; + + payloadTestCases.forEach(({ name, payload }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(payload); + expect(encrypted).toEqual( + expect.objectContaining({ + encrypted: expect.any(Array), + salt: expect.any(Array), + iv: expect.any(Array), + algorithm: 'AES-GCM', + timestamp: expect.any(Number), + }), + ); + + const decrypted = await testHelpers.decryptWithFixtures(encrypted); + expect(decrypted).toEqual(payload); + }); + }); + }); + + describe('Error Handling', () => { + const errorTestCases = [ + { + name: 'invalid algorithm', + setup: (encrypted) => { + const modified = { ...encrypted, algorithm: 'INVALID-ALGORITHM' }; + return modified; + }, + }, + { + name: 'malformed encrypted data', + setup: (encrypted) => { + const modified = { ...encrypted, encrypted: 'not-an-array' }; + return modified; + }, + }, + ]; + + errorTestCases.forEach(({ name, setup }) => { + it(`handles ${name}`, async () => { + const encrypted = await testHelpers.encryptWithFixtures(); + const modifiedEncrypted = setup(encrypted); + + await expect(testHelpers.decryptWithFixtures(modifiedEncrypted)).rejects.toThrow( + 'Crypto operation failed', + ); + }); + }); + }); + + describe('Performance Tests', () => { + const PERFORMANCE_THRESHOLD_MS = 100; + + async function measureTime(operation) { + const startTime = performance.now(); + await operation(); + return performance.now() - startTime; + } + + it('encrypts and decrypts within reasonable time limits', async () => { + const encryptionTime = await measureTime(() => testHelpers.encryptWithFixtures()); + + const encrypted = await testHelpers.encryptWithFixtures(); + const decryptionTime = await measureTime(() => testHelpers.decryptWithFixtures(encrypted)); + + expect(encryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + expect(decryptionTime).toBeLessThan(PERFORMANCE_THRESHOLD_MS); + }); + + it('handles multiple concurrent operations', async () => { + const operations = Array.from({ length: 10 }, async (_, i) => { + const payload = { ...fixtures.payload, counter: i }; + const encrypted = await testHelpers.encryptWithFixtures(payload); + return testHelpers.decryptWithFixtures(encrypted); + }); + + const results = await Promise.all(operations); + + results.forEach((result, index) => { + expect(result.counter).toBe(index); + }); + }); + }); + + describe('Cross-Application Integration', () => { + it('simulates full message flow between Vue and React', async () => { + const vueMessage = { + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: await testHelpers.encryptWithFixtures(), + encrypted: true, + }; + + expect(vueMessage).toEqual( + expect.objectContaining({ + encrypted: true, + type: 'O11Y_JWT_LOGIN_ENCRYPTED', + payload: expect.any(Object), + }), + ); + + const decryptedInReact = await testHelpers.decryptWithFixtures(vueMessage.payload); + expect(decryptedInReact).toEqual(fixtures.payload); + + const reactResponse = { + type: 'O11Y_AUTH_STATUS', + authenticated: true, + timestamp: Date.now(), + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }; + + expect(reactResponse).toEqual( + expect.objectContaining({ + authenticated: true, + nonce: fixtures.payload.nonce, + counter: fixtures.payload.counter, + }), + ); + }); + }); +}); diff --git a/spec/frontend/observability/utils/message_validator_spec.js b/spec/frontend/observability/utils/message_validator_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..62bd54bee0884e709040c73af1f95b4f0b627039 --- /dev/null +++ b/spec/frontend/observability/utils/message_validator_spec.js @@ -0,0 +1,426 @@ +import { MessageValidator, createMessageValidator } from '~/observability/utils/message_validator'; +import { MESSAGE_TYPES, TIMEOUTS } from '~/observability/constants'; + +jest.mock('~/locale', () => ({ + s__: jest.fn(() => 'Message validation failed'), +})); + +describe('MessageValidator', () => { + let validator; + const mockNow = 1000000; + const config = { + allowedOrigin: 'https://trusted-origin.com', + allowedMessageType: MESSAGE_TYPES.AUTH_STATUS, + maxMessageAge: TIMEOUTS.MAX_MESSAGE_AGE, + }; + + const createEvent = (overrides = {}) => ({ + origin: config.allowedOrigin, + data: { + type: config.allowedMessageType, + timestamp: mockNow - 1000, + nonce: 'valid-nonce', + counter: 1, + ...overrides, + }, + }); + + beforeEach(() => { + validator = new MessageValidator(config.allowedOrigin, config.allowedMessageType, { + maxMessageAge: config.maxMessageAge, + }); + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('initializes with provided configuration', () => { + expect(validator.allowedOrigin).toBe(config.allowedOrigin); + expect(validator.allowedMessageType).toBe(config.allowedMessageType); + expect(validator.maxMessageAge).toBe(config.maxMessageAge); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.timestampTolerance).toBe(5000); + }); + + it('applies sensible defaults when options are omitted', () => { + const defaultValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + ); + expect(defaultValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + + it('accepts custom configuration options', () => { + const customValidator = new MessageValidator( + config.allowedOrigin, + config.allowedMessageType, + { + timestampTolerance: 10000, + }, + ); + expect(customValidator.timestampTolerance).toBe(10000); + }); + }); + + describe('validateMessage', () => { + it('accepts completely valid messages', () => { + const event = createEvent(); + const result = validator.validateMessage(event, 'valid-nonce', 1); + expect(result).toEqual({ valid: true }); + }); + + describe('rejects invalid messages', () => { + const invalidCases = [ + { + name: 'wrong origin', + event: () => createEvent(), + modifier: (event) => ({ ...event, origin: 'https://malicious-origin.com' }), + }, + { + name: 'malformed data structure', + event: () => ({ origin: config.allowedOrigin, data: null }), + modifier: () => {}, + }, + { + name: 'incorrect message type', + event: () => createEvent({ type: 'INVALID_TYPE' }), + modifier: () => {}, + }, + { + name: 'nonce mismatch', + event: () => createEvent(), + modifier: () => {}, + nonce: 'wrong-nonce', + }, + { + name: 'counter mismatch', + event: () => createEvent(), + modifier: () => {}, + counter: 2, + }, + { + name: 'stale timestamp', + event: () => createEvent({ timestamp: 1 }), + modifier: () => {}, + }, + ]; + + invalidCases.forEach(({ name, event, modifier, nonce = 'valid-nonce', counter = 1 }) => { + it(`when ${name}`, () => { + const testEvent = event(); + const modifiedEvent = modifier(testEvent) || testEvent; + const result = validator.validateMessage(modifiedEvent, nonce, counter); + expect(result).toEqual({ valid: false, error: 'Message validation failed' }); + }); + }); + }); + }); + + describe('origin validation', () => { + const testCases = [ + { input: config.allowedOrigin, expected: true, description: 'matching origin' }, + { input: 'https://malicious-origin.com', expected: false, description: 'different origin' }, + { input: null, expected: false, description: 'null origin' }, + { input: undefined, expected: false, description: 'undefined origin' }, + ]; + + testCases.forEach(({ input, expected, description }) => { + it(`${expected ? 'accepts' : 'rejects'} ${description}`, () => { + expect(validator.validateOrigin(input)).toBe(expected); + }); + }); + }); + + describe('data structure validation', () => { + const validStructure = { + type: 'string-type', + timestamp: 123456789, + nonce: 'string-nonce', + counter: 42, + }; + + it('accepts well-formed data objects', () => { + expect(validator.validateStructure(validStructure)).toBe(true); + }); + + describe('rejects malformed data', () => { + const invalidCases = [ + { input: null, reason: 'null data' }, + { input: undefined, reason: 'undefined data' }, + { input: 'string', reason: 'string instead of object' }, + { input: 123, reason: 'number instead of object' }, + { input: [], reason: 'array instead of object' }, + { input: { ...validStructure, type: 123 }, reason: 'non-string type' }, + { input: { ...validStructure, timestamp: '123456789' }, reason: 'non-numeric timestamp' }, + { input: { ...validStructure, nonce: 123 }, reason: 'non-string nonce' }, + { input: { ...validStructure, counter: '42' }, reason: 'non-numeric counter' }, + ]; + + invalidCases.forEach(({ input, reason }) => { + it(`when data has ${reason}`, () => { + expect(validator.validateStructure(input)).toBe(false); + }); + }); + + it('when required fields are missing', () => { + const requiredFields = ['type', 'timestamp', 'nonce', 'counter']; + requiredFields.forEach((field) => { + const incompleteData = { ...validStructure }; + delete incompleteData[field]; + expect(validator.validateStructure(incompleteData)).toBe(false); + }); + }); + }); + }); + + describe('message type validation', () => { + const testCases = [ + { input: config.allowedMessageType, expected: true }, + { input: 'WRONG_TYPE', expected: false }, + { input: null, expected: false }, + { input: undefined, expected: false }, + ]; + + testCases.forEach(({ input, expected }) => { + it(`${expected ? 'accepts' : 'rejects'} ${input || 'falsy values'}`, () => { + expect(validator.validateType(input)).toBe(expected); + }); + }); + }); + + describe('nonce validation', () => { + it('accepts matching nonces', () => { + expect(validator.validateNonce('test-nonce', 'test-nonce')).toBe(true); + }); + + const invalidCases = [ + { actual: 'nonce1', expected: 'nonce2', reason: 'mismatched nonces' }, + { actual: null, expected: 'test', reason: 'null actual nonce' }, + { actual: 'test', expected: null, reason: 'null expected nonce' }, + { actual: undefined, expected: 'test', reason: 'undefined actual nonce' }, + { actual: 'test', expected: undefined, reason: 'undefined expected nonce' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateNonce(actual, expected)).toBe(false); + }); + }); + }); + + describe('counter validation', () => { + it('accepts matching counters', () => { + expect(validator.validateCounter(42, 42)).toBe(true); + }); + + const invalidCases = [ + { actual: 1, expected: 2, reason: 'mismatched counters' }, + { actual: null, expected: 1, reason: 'null actual counter' }, + { actual: 1, expected: null, reason: 'null expected counter' }, + { actual: undefined, expected: 1, reason: 'undefined actual counter' }, + { actual: 1, expected: undefined, reason: 'undefined expected counter' }, + ]; + + invalidCases.forEach(({ actual, expected, reason }) => { + it(`rejects ${reason}`, () => { + expect(validator.validateCounter(actual, expected)).toBe(false); + }); + }); + }); + + describe('timestamp validation', () => { + beforeEach(() => { + validator.reset(); + }); + + describe('message age validation', () => { + it('accepts recent timestamps', () => { + const recentTimestamp = mockNow - 1000; + expect(validator.validateTimestamp(recentTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(recentTimestamp); + }); + + it('rejects timestamps older than maximum age', () => { + const staleTimestamp = mockNow - config.maxMessageAge - 1000; + expect(validator.validateTimestamp(staleTimestamp)).toBe(false); + }); + + it('accepts timestamps at the exact age boundary', () => { + const boundaryTimestamp = mockNow - config.maxMessageAge; + expect(validator.validateTimestamp(boundaryTimestamp)).toBe(true); + }); + + it('rejects future timestamps beyond clock skew tolerance', () => { + const futureTimestamp = mockNow + config.maxMessageAge + 1000; + expect(validator.validateTimestamp(futureTimestamp)).toBe(false); + }); + }); + + describe('duplicate timestamp detection', () => { + it('rejects exact duplicate timestamps', () => { + const timestamp = mockNow - 1000; + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + describe('out-of-order message handling', () => { + const baseTimestamp = mockNow - 1000; + const tolerance = 5000; + + beforeEach(() => { + validator.validateTimestamp(baseTimestamp); + }); + + it('allows messages within tolerance window', () => { + const withinTolerance = [ + baseTimestamp - 2000, + baseTimestamp - 4000, + baseTimestamp + 2000, + baseTimestamp + 4000, + ]; + + withinTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('rejects messages outside tolerance window', () => { + const outsideTolerance = [ + baseTimestamp - (tolerance + 1000), + baseTimestamp - (tolerance + 2000), + ]; + + outsideTolerance.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(false); + }); + }); + + it('updates last timestamp when receiving newer messages within tolerance', () => { + const newerTimestamp = baseTimestamp + 3000; + expect(validator.validateTimestamp(newerTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(newerTimestamp); + }); + + it('preserves last timestamp when receiving older messages within tolerance', () => { + const olderTimestamp = baseTimestamp - 3000; + expect(validator.validateTimestamp(olderTimestamp)).toBe(true); + expect(validator.lastMessageTimestamp).toBe(baseTimestamp); + }); + }); + + it('handles progressive timestamp advancement correctly', () => { + const timestamps = [995000, 1005000, 1015000]; + timestamps.forEach((timestamp) => { + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + }); + + it('manages cleanup of old timestamps from tracking set', () => { + const recentTimestamp = mockNow - 1000; + validator.validateTimestamp(recentTimestamp); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(true); + + validator.cleanupOldTimestamps(mockNow + config.maxMessageAge + 2000); + expect(validator.recentTimestamps.has(recentTimestamp)).toBe(false); + }); + }); + + describe('validator state management', () => { + it('properly resets all state when reset() is called', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + expect(validator.lastMessageTimestamp).not.toBe(0); + expect(validator.recentTimestamps.size).toBeGreaterThan(0); + + validator.reset(); + expect(validator.lastMessageTimestamp).toBe(0); + expect(validator.recentTimestamps.size).toBe(0); + }); + + it('allows reprocessing previously seen timestamps after reset', () => { + const timestamp = mockNow - 1000; + + expect(validator.validateTimestamp(timestamp)).toBe(true); + expect(validator.validateTimestamp(timestamp)).toBe(false); + + validator.reset(); + expect(validator.validateTimestamp(timestamp)).toBe(true); + }); + + it('provides comprehensive validation statistics', () => { + validator.validateTimestamp(mockNow - 1000); + validator.trackNonce('test-nonce'); + + const stats = validator.getValidationStats(); + expect(stats).toEqual({ + lastMessageTimestamp: mockNow - 1000, + processedNoncesCount: 1, + recentTimestampsCount: 1, + allowedOrigin: config.allowedOrigin, + allowedMessageType: config.allowedMessageType, + timestampTolerance: 5000, + }); + }); + }); + + describe('factory function', () => { + it('creates properly configured MessageValidator instances', () => { + const createdValidator = createMessageValidator('https://example.com'); + + expect(createdValidator).toBeInstanceOf(MessageValidator); + expect(createdValidator.allowedOrigin).toBe('https://example.com'); + expect(createdValidator.allowedMessageType).toBe(MESSAGE_TYPES.AUTH_STATUS); + expect(createdValidator.maxMessageAge).toBe(TIMEOUTS.MAX_MESSAGE_AGE); + }); + }); + + describe('integration scenarios', () => { + it('processes sequential valid messages correctly', () => { + const messages = [ + { timestamp: 995000, nonce: 'nonce-1', counter: 1 }, + { timestamp: 996000, nonce: 'nonce-2', counter: 2 }, + { timestamp: 997000, nonce: 'nonce-3', counter: 3 }, + ]; + + messages.forEach(({ timestamp, nonce, counter }) => { + const event = createEvent({ timestamp, nonce, counter }); + expect(validator.validateMessage(event, nonce, counter)).toEqual({ valid: true }); + }); + + expect(validator.lastMessageTimestamp).toBe(997000); + }); + + it('maintains state consistency during mixed valid/invalid message processing', () => { + const validEvent = createEvent({ timestamp: 995000, nonce: 'valid-nonce', counter: 1 }); + const invalidEvent = createEvent({ + timestamp: 996000, + nonce: 'invalid-nonce', + counter: 2, + }); + + expect(validator.validateMessage(validEvent, 'valid-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(invalidEvent, 'expected-nonce', 2)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + + const nextValidEvent = createEvent({ timestamp: 997000, nonce: 'next-nonce', counter: 3 }); + expect(validator.validateMessage(nextValidEvent, 'next-nonce', 3)).toEqual({ valid: true }); + }); + + it('handles attempt to replay previously processed messages', () => { + const event = createEvent({ timestamp: 995000, nonce: 'replay-nonce', counter: 1 }); + + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ valid: true }); + expect(validator.validateMessage(event, 'replay-nonce', 1)).toEqual({ + valid: false, + error: 'Message validation failed', + }); + }); + }); +});