diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index 2a9c036..879bbee 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -42,5 +42,5 @@ jobs: with: version: 1.9.4 - name: Biome Check - run: biome ci --formatter-enabled=false --changed + run: biome ci --formatter-enabled=false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f7df520 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: + - main + paths: + - '**/*.ts' + - '**/*.js' + pull_request: + branches: + - main + paths: + - '**/*.ts' + - '**/*.js' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.6.5 + run_install: true + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + - name: Run Tests + run: pnpm test \ No newline at end of file diff --git a/package.json b/package.json index be93b1c..e639d13 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint:ts": "tsc --noEmit --pretty", "format": "biome format --write .", "build": "tsup", - "test": "vitest", + "test": "vitest run", "prepublishOnly": "pnpm build", "prepare": "husky" }, @@ -30,6 +30,7 @@ "packageManager": "pnpm@10.6.5", "devDependencies": { "@biomejs/biome": "1.9.4", + "@types/node": "^22.13.11", "husky": "^9.1.7", "lint-staged": "^15.5.0", "tsup": "^8.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39aaa1f..391965d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@biomejs/biome': specifier: 1.9.4 version: 1.9.4 + '@types/node': + specifier: ^22.13.11 + version: 22.13.11 husky: specifier: ^9.1.7 version: 9.1.7 @@ -25,7 +28,7 @@ importers: version: 5.8.2 vitest: specifier: ^3.0.9 - version: 3.0.9(yaml@2.7.0) + version: 3.0.9(@types/node@22.13.11)(yaml@2.7.0) packages: @@ -356,6 +359,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/node@22.13.11': + resolution: {integrity: sha512-iEUCUJoU0i3VnrCmgoWCXttklWcvoCIx4jzcP22fioIVSdTmjgoEvmAO/QPw6TcS9k5FrNgn4w7q5lGOd1CT5g==} + '@vitest/expect@3.0.9': resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} @@ -916,6 +922,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + vite-node@3.0.9: resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1222,6 +1231,10 @@ snapshots: '@types/estree@1.0.6': {} + '@types/node@22.13.11': + dependencies: + undici-types: 6.20.0 + '@vitest/expect@3.0.9': dependencies: '@vitest/spy': 3.0.9 @@ -1229,13 +1242,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.9(vite@6.2.2(yaml@2.7.0))': + '@vitest/mocker@3.0.9(vite@6.2.2(@types/node@22.13.11)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.9 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.2.2(yaml@2.7.0) + vite: 6.2.2(@types/node@22.13.11)(yaml@2.7.0) '@vitest/pretty-format@3.0.9': dependencies: @@ -1762,13 +1775,15 @@ snapshots: typescript@5.8.2: {} - vite-node@3.0.9(yaml@2.7.0): + undici-types@6.20.0: {} + + vite-node@3.0.9(@types/node@22.13.11)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.2(yaml@2.7.0) + vite: 6.2.2(@types/node@22.13.11)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1783,19 +1798,20 @@ snapshots: - tsx - yaml - vite@6.2.2(yaml@2.7.0): + vite@6.2.2(@types/node@22.13.11)(yaml@2.7.0): dependencies: esbuild: 0.25.1 postcss: 8.5.3 rollup: 4.36.0 optionalDependencies: + '@types/node': 22.13.11 fsevents: 2.3.3 yaml: 2.7.0 - vitest@3.0.9(yaml@2.7.0): + vitest@3.0.9(@types/node@22.13.11)(yaml@2.7.0): dependencies: '@vitest/expect': 3.0.9 - '@vitest/mocker': 3.0.9(vite@6.2.2(yaml@2.7.0)) + '@vitest/mocker': 3.0.9(vite@6.2.2(@types/node@22.13.11)(yaml@2.7.0)) '@vitest/pretty-format': 3.0.9 '@vitest/runner': 3.0.9 '@vitest/snapshot': 3.0.9 @@ -1811,9 +1827,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.2.2(yaml@2.7.0) - vite-node: 3.0.9(yaml@2.7.0) + vite: 6.2.2(@types/node@22.13.11)(yaml@2.7.0) + vite-node: 3.0.9(@types/node@22.13.11)(yaml@2.7.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.13.11 transitivePeerDependencies: - jiti - less diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..8a719f5 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,4 @@ +interface ImportMeta { + // biome-ignore lint/suspicious/noExplicitAny: Environment variables are often strings, but can be any type + env: Record; +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..9d26741 --- /dev/null +++ b/src/secrets.ts @@ -0,0 +1,183 @@ +import { type EnvObject, EnvTarget } from './types'; +import { base64ToUint8Array, uint8ArrayToBase64 } from './utils'; + +const PBKDF2_ROUNDS = process.env.GITOPS_SECRETS_PBKDF2_ROUNDS || 1000000; +const PBKDF2_KEYLEN = 32; +const PBKDF2_DIGEST = 'SHA-256'; +const ALGORITHM = 'AES-GCM'; +const AES_IV_BYTES = 12; +const AES_SALT_BYTES = 8; +const ENCODING = 'base64'; +const TEXT_ENCODING = 'utf8'; + +function masterKey() { + if (!process.env.GITOPS_SECRETS_MASTER_KEY || process.env.GITOPS_SECRETS_MASTER_KEY.length < 16) { + throw new Error( + `The 'GITOPS_SECRETS_MASTER_KEY' environment variable must be set to a string of 16 characters or more`, + ); + } + + return process.env.GITOPS_SECRETS_MASTER_KEY; +} + +/** + * Derive encryption key using the Web Crypto API's PBKDF2 + * + * @param {string} masterKeyString - The master key string + * @param {Uint8Array} salt - The salt for key derivation + * @param {number} iterations - The number of iterations for key derivation + * @returns {Promise} - The derived key + */ +async function deriveKey( + masterKeyString: string, + salt: Uint8Array, + iterations: number = Number(PBKDF2_ROUNDS), +): Promise { + const masterKeyBuffer = new TextEncoder().encode(masterKeyString); + const importedKey = await crypto.subtle.importKey('raw', masterKeyBuffer, { name: 'PBKDF2' }, false, ['deriveKey']); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: PBKDF2_DIGEST, + }, + importedKey, + { name: ALGORITHM, length: PBKDF2_KEYLEN * 8 }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Encrypt secrets to a secure format + * + * @param {string} secrets - The data to encrypt + * @returns {Promise} - Encrypted data in format "base64:rounds:salt:iv:encryptedData" + */ +async function encrypt(secrets: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(AES_SALT_BYTES)); + const iv = crypto.getRandomValues(new Uint8Array(AES_IV_BYTES)); + const key = await deriveKey(masterKey(), salt); + + const dataBuffer = new TextEncoder().encode(secrets); + const encryptedBuffer = await crypto.subtle.encrypt( + { + name: ALGORITHM, + iv: iv, + }, + key, + dataBuffer, + ); + + const saltBase64 = uint8ArrayToBase64(salt); + const ivBase64 = uint8ArrayToBase64(iv); + const encryptedBase64 = uint8ArrayToBase64(new Uint8Array(encryptedBuffer)); + + return `${ENCODING}:${PBKDF2_ROUNDS}:${saltBase64}:${ivBase64}:${encryptedBase64}`; +} + +/** + * Decrypt secrets from secure format + * + * @param {string} ciphertext - Data in format "base64:rounds:salt:iv:encryptedData" + * @returns {Promise} - Decrypted data + */ +async function decrypt(ciphertext: string): Promise { + const encodedData = ciphertext.startsWith(`${ENCODING}:`) ? ciphertext.substring(`${ENCODING}:`.length) : ciphertext; + + const parts = encodedData.split(':'); + if (parts.length !== 4) { + throw new Error(`Encrypted payload invalid. Expected 4 sections but got ${parts.length}`); + } + + const rounds = Number.parseInt(parts[0], 10); + const salt = base64ToUint8Array(parts[1]); + const iv = base64ToUint8Array(parts[2]); + const encryptedContent = base64ToUint8Array(parts[3]); + + try { + const key = await deriveKey(masterKey(), salt, rounds); + + const decryptedBuffer = await crypto.subtle.decrypt( + { + name: ALGORITHM, + iv: iv, + }, + key, + encryptedContent, + ); + + const decrypted = new TextDecoder(TEXT_ENCODING).decode(decryptedBuffer); + return decrypted; + } catch (error) { + throw new Error(`Decryption failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Get the appropriate environment object based on the target + * + * @param {EnvTarget} target - The environment target + * @returns {EnvObject} - The environment object + */ +function getEnvObject(target: EnvTarget): EnvObject { + switch (target) { + case EnvTarget.PROCESS: + if (typeof process !== 'undefined' && process.env) { + return process.env; + } + throw new Error('process.env is not available in this environment'); + case EnvTarget.IMPORT_META: + if (typeof import.meta !== 'undefined' && import.meta.env) { + return import.meta.env; + } + throw new Error('import.meta.env is not available in this environment'); + + default: + throw new Error(`Unsupported environment target: ${target}`); + } +} + +/** + * Merge secrets payload into the specified environment + * + * @param {Record} payload - The payload object containing secrets + * @param {EnvTarget} target - The environment target to merge secrets into + * @returns {EnvObject} - The environment object with merged secrets + */ +function mergeSecrets(payload: Record, target: EnvTarget): EnvObject { + const envObject = getEnvObject(target); + + for (const [key, value] of Object.entries(payload)) { + if (target === EnvTarget.PROCESS && typeof process !== 'undefined') { + process.env[key] = value; + } else if (target === EnvTarget.IMPORT_META) { + import.meta.env[key] = value; + } + } + + return envObject; +} + +/** + * Load encrypted secrets, decrypt them, and merge into the specified environment + * + * @param {string} encryptedSecrets - The encrypted secrets string + * @param {EnvTarget} target - The environment target to merge with + * @returns {Promise} - The environment with merged secrets + */ +async function loadSecrets(encryptedSecrets: string, target: EnvTarget = EnvTarget.PROCESS): Promise { + try { + const decryptedJson = await decrypt(encryptedSecrets); + const secretsPayload = JSON.parse(decryptedJson) as Record; + + return mergeSecrets(secretsPayload, target); + } catch (error) { + console.error('Failed to load secrets:', error); + return getEnvObject(target); + } +} + +export { encrypt, decrypt, mergeSecrets, loadSecrets }; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..af5c2ed --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +export enum EnvTarget { + PROCESS = 'process', + IMPORT_META = 'import.meta', +} + +type EnvObject = { + [key: string]: string | boolean | number | undefined | null | object; +}; + +export type { EnvObject }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c89d0e7 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,32 @@ +/** + * Convert a base64 string to a Uint8Array + * + * @param {string} base64 + * @returns {Uint8Array} + */ +function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes; +} + +/** + * Convert a Uint8Array to a base64 string + * + * @param {Uint8Array} buffer - The binary data to convert + * @returns {string} - Base64 encoded string + */ +function uint8ArrayToBase64(buffer: Uint8Array): string { + return btoa( + Array.from(buffer) + .map((byte) => String.fromCharCode(byte)) + .join(''), + ); +} + +export { uint8ArrayToBase64, base64ToUint8Array }; diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts new file mode 100644 index 0000000..d2629ee --- /dev/null +++ b/tests/secrets.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { encrypt, decrypt, mergeSecrets, loadSecrets } from '../src/secrets'; +import { EnvTarget } from '../src/types'; + +process.env.GITOPS_SECRETS_MASTER_KEY = 'test-master-key-16chars+'; + +describe('Secrets module', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.GITOPS_SECRETS_MASTER_KEY = 'test-master-key-16chars+'; + + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.getRandomValues as any) = vi.fn((array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i + 1; + } + return array; + }); + + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.importKey as any) = vi.fn().mockResolvedValue({} as CryptoKey); + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.deriveKey as any) = vi.fn().mockResolvedValue({} as CryptoKey); + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.encrypt as any) = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer); + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.decrypt as any) = vi + .fn() + .mockResolvedValue(new TextEncoder().encode(JSON.stringify({ TEST_SECRET: 'test-value' })).buffer); + + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + describe('encrypt', () => { + it('should encrypt data with expected format', async () => { + const result = await encrypt('test-secret'); + + expect(result).toMatch(/^base64:\d+:[A-Za-z0-9+/]+=*:[A-Za-z0-9+/]+=*:[A-Za-z0-9+/]+=*$/); + + expect(global.crypto.getRandomValues).toHaveBeenCalledTimes(2); + expect(global.crypto.subtle.importKey).toHaveBeenCalledTimes(1); + expect(global.crypto.subtle.deriveKey).toHaveBeenCalledTimes(1); + expect(global.crypto.subtle.encrypt).toHaveBeenCalledTimes(1); + }); + }); + + describe('decrypt', () => { + it('should decrypt properly formatted data', async () => { + const mockDecrypted = 'decrypted-test-data'; + const mockDecryptedBuffer = new TextEncoder().encode(mockDecrypted); + + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.decrypt as any) = vi.fn().mockResolvedValue(mockDecryptedBuffer.buffer); + + const encrypted = 'base64:1000000:AQIDBAUG:CQoLDA0ODxARElM=:FRUWEQ=='; + const result = await decrypt(encrypted); + + expect(result).toBe(mockDecrypted); + expect(global.crypto.subtle.decrypt).toHaveBeenCalledTimes(1); + }); + + it('should throw error for invalid data format', async () => { + const invalidData = 'base64:invalid-format'; + await expect(decrypt(invalidData)).rejects.toThrow('Encrypted payload invalid'); + }); + }); + + describe('mergeSecrets', () => { + it('should merge secrets into process.env', () => { + const payload = { TEST_SECRET: 'test-value', ANOTHER_SECRET: 'another-value' }; + + mergeSecrets(payload, EnvTarget.PROCESS); + + expect(process.env.TEST_SECRET).toBe('test-value'); + expect(process.env.ANOTHER_SECRET).toBe('another-value'); + }); + }); + + describe('loadSecrets', () => { + it('should decrypt and merge secrets', async () => { + const testPayload = { + TEST_SECRET: 'test-value', + ANOTHER_SECRET: 'another-value', + }; + const mockJson = JSON.stringify(testPayload); + const mockJsonBuffer = new TextEncoder().encode(mockJson); + + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.decrypt as any) = vi.fn().mockResolvedValue(mockJsonBuffer.buffer); + + const encryptedData = 'base64:1000000:AQIDBAUG:CQoLDA0ODxARElM=:FRUWEQ=='; + await loadSecrets(encryptedData); + + expect(process.env.TEST_SECRET).toBe('test-value'); + expect(process.env.ANOTHER_SECRET).toBe('another-value'); + }); + + it('should handle decryption errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // biome-ignore lint/suspicious/noExplicitAny: Hard to type this correctly + (global.crypto.subtle.decrypt as any) = vi.fn().mockRejectedValue(new Error('Decryption failed')); + + const encryptedData = 'invalid-data'; + const result = await loadSecrets(encryptedData); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to load secrets'), + expect.any(Error), + ); + expect(result).toBe(process.env); + + consoleErrorSpy.mockRestore(); + }); + }); +});