From 03c413827f08f46a42101b160f515b06e346393c Mon Sep 17 00:00:00 2001 From: dakotadux Date: Mon, 28 Jul 2025 15:28:24 -0600 Subject: [PATCH] Add utils/crypto.js to encrypt payloads This will be used to encrypt the payloads sent to the observability iframe. --- .../javascripts/observability/utils/crypto.js | 165 +++++++++ locale/gitlab.pot | 12 + .../observability/utils/crypto_spec.js | 329 ++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 app/assets/javascripts/observability/utils/crypto.js create mode 100644 spec/frontend/observability/utils/crypto_spec.js diff --git a/app/assets/javascripts/observability/utils/crypto.js b/app/assets/javascripts/observability/utils/crypto.js new file mode 100644 index 00000000000000..9e033a9456b97b --- /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 + 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/locale/gitlab.pot b/locale/gitlab.pot index bffbc7381cead2..c5461c03e201e7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43134,6 +43134,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 "" @@ -43197,6 +43203,9 @@ msgstr "" msgid "Observability|Last 7 days" msgstr "" +msgid "Observability|Length must be a positive integer" +msgstr "" + msgid "Observability|O11y Service Settings" msgstr "" @@ -43248,6 +43257,9 @@ 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 "" diff --git a/spec/frontend/observability/utils/crypto_spec.js b/spec/frontend/observability/utils/crypto_spec.js new file mode 100644 index 00000000000000..911836e17f7aa8 --- /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, + }), + ); + }); + }); +}); -- GitLab