From d9fc734214e1d8d3474bed5acddc398df85e95b7 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Wed, 15 Oct 2025 10:46:40 +0200 Subject: [PATCH 1/5] encrypt env vars --- src/registry/domain/repository.ts | 53 ++++++++++- src/types.ts | 9 ++ src/utils/env-encryption.ts | 100 +++++++++++++++++++++ test/unit/registry-domain-repository.js | 5 +- test/unit/utils-env-encryption.js | 115 ++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 src/utils/env-encryption.ts create mode 100644 test/unit/utils-env-encryption.js diff --git a/src/registry/domain/repository.ts b/src/registry/domain/repository.ts index c8e0ae200..c68fe3568 100644 --- a/src/registry/domain/repository.ts +++ b/src/registry/domain/repository.ts @@ -12,6 +12,7 @@ import type { Config, TemplateInfo } from '../../types'; +import * as envEncryption from '../../utils/env-encryption'; import errorToString from '../../utils/error-to-string'; import ComponentsCache from './components-cache'; import getComponentsDetails from './components-details'; @@ -121,7 +122,19 @@ export default function repository(conf: Config) { ); const filePath = path.join(conf.path, componentName, pkg.oc.files.env!); - return dotenv.parse(fs.readFileSync(filePath).toString()); + let envContent = fs.readFileSync(filePath).toString(); + + // Decrypt if encrypted + if (envEncryption.isEncrypted(envContent)) { + if (!conf.envEncryptionKey) { + throw new Error( + 'ENV_DECRYPTION_ERROR: .env file is encrypted but no envEncryptionKey configured' + ); + } + envContent = envEncryption.decrypt(envContent, conf.envEncryptionKey); + } + + return dotenv.parse(envContent); } }; @@ -264,9 +277,19 @@ export default function repository(conf: Config) { } const filePath = getFilePath(componentName, componentVersion, '.env'); - const file = await cdn.getFile(filePath); + let envContent = await cdn.getFile(filePath); + + // Decrypt if encrypted + if (envEncryption.isEncrypted(envContent)) { + if (!conf.envEncryptionKey) { + throw new Error( + 'ENV_DECRYPTION_ERROR: .env file is encrypted but no envEncryptionKey configured' + ); + } + envContent = envEncryption.decrypt(envContent, conf.envEncryptionKey); + } - return dotenv.parse(file); + return dotenv.parse(envContent); }, getStaticClientPath: (dev?: boolean): string => `${options!['path']}${getFilePath( @@ -382,6 +405,30 @@ export default function repository(conf: Config) { pkgDetails.packageJson ); + // Handle .env file encryption if present + const envFilePath = path.join(pkgDetails.outputFolder, '.env'); + if (await fs.pathExists(envFilePath)) { + if (conf.envEncryptionKey) { + try { + const envContent = await fs.readFile(envFilePath, 'utf8'); + const encryptedContent = envEncryption.encrypt( + envContent, + conf.envEncryptionKey + ); + await fs.writeFile(envFilePath, encryptedContent, 'utf8'); + } catch (err) { + throw { + code: 'env_encryption_error', + msg: `Failed to encrypt .env file: ${errorToString(err)}` + }; + } + } else { + console.warn( + `WARNING: Publishing component "${componentName}" with unencrypted .env file. Set envEncryptionKey in config to enable encryption.` + ); + } + } + await cdn.putDir( pkgDetails.outputFolder, `${options!.componentsDir}/${componentName}/${componentVersion}` diff --git a/src/types.ts b/src/types.ts index 234f4d2e3..7c8d3198d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -395,6 +395,15 @@ export interface Config { * @default 0 */ verbosity: number; + /** + * Encryption key for securing component .env files in storage. + * Must be a 32-byte key represented as 64 hexadecimal characters. + * When set, .env files will be encrypted before storage and decrypted on retrieval. + * When not set, .env files are stored as plaintext (with a warning). + * + * @example "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + */ + envEncryptionKey?: string; } type CompiledTemplate = (model: unknown) => string; diff --git a/src/utils/env-encryption.ts b/src/utils/env-encryption.ts new file mode 100644 index 000000000..84a18a2db --- /dev/null +++ b/src/utils/env-encryption.ts @@ -0,0 +1,100 @@ +import crypto from 'node:crypto'; + +const ENCRYPTION_VERSION = 'OC_ENCRYPTED_V1'; +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 12 bytes for GCM + +/** + * Validates that the encryption key is properly formatted (32 bytes / 64 hex chars) + */ +function validateEncryptionKey(key: string): void { + if (!key || typeof key !== 'string') { + throw new Error('Encryption key must be a non-empty string'); + } + + // Remove any whitespace + const cleanKey = key.trim(); + + // Check if it's a valid hex string of 64 characters (32 bytes) + if (!/^[0-9a-fA-F]{64}$/.test(cleanKey)) { + throw new Error( + 'Encryption key must be 64 hexadecimal characters (32 bytes)' + ); + } +} + +/** + * Checks if content is encrypted by looking for the version header + */ +export function isEncrypted(content: string): boolean { + return content.startsWith(`${ENCRYPTION_VERSION}:`); +} + +/** + * Encrypts content using AES-256-GCM + * Returns format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} + */ +export function encrypt(content: string, key: string): string { + validateEncryptionKey(key); + + // Generate random IV + const iv = crypto.randomBytes(IV_LENGTH); + + // Create cipher + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv); + + // Encrypt the content + let encrypted = cipher.update(content, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + // Get auth tag + const authTag = cipher.getAuthTag(); + + // Combine into our format + return `${ENCRYPTION_VERSION}:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; +} + +/** + * Decrypts content that was encrypted with the encrypt function + * Expects format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} + */ +export function decrypt(content: string, key: string): string { + validateEncryptionKey(key); + + // Check for encrypted header + if (!isEncrypted(content)) { + throw new Error('Content is not encrypted or uses an unknown format'); + } + + // Parse the encrypted content + const parts = content.split(':'); + if (parts.length !== 4) { + throw new Error('Invalid encrypted content format'); + } + + const [version, ivBase64, authTagBase64, encryptedData] = parts; + + if (version !== ENCRYPTION_VERSION) { + throw new Error(`Unsupported encryption version: ${version}`); + } + + // Decode components + const iv = Buffer.from(ivBase64, 'base64'); + const authTag = Buffer.from(authTagBase64, 'base64'); + + // Create decipher + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(key, 'hex'), + iv + ); + + // Set auth tag + decipher.setAuthTag(authTag); + + // Decrypt + let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/test/unit/registry-domain-repository.js b/test/unit/registry-domain-repository.js index d0e9cf834..c0a4794bd 100644 --- a/test/unit/registry-domain-repository.js +++ b/test/unit/registry-domain-repository.js @@ -34,7 +34,10 @@ describe('registry : domain : repository', () => { readdirSync: fs.readdirSync, lstatSync: fs.lstatSync, readJsonSync: fs.readJsonSync, - writeJson: () => Promise.resolve() + writeJson: () => Promise.resolve(), + pathExists: () => Promise.resolve(false), + readFile: () => Promise.resolve(''), + writeFile: () => Promise.resolve() }; const s3Mock = { diff --git a/test/unit/utils-env-encryption.js b/test/unit/utils-env-encryption.js new file mode 100644 index 000000000..8f30f5285 --- /dev/null +++ b/test/unit/utils-env-encryption.js @@ -0,0 +1,115 @@ +const expect = require('chai').expect; +const injectr = require('injectr'); + +describe('utils : env-encryption', () => { + let envEncryption; + + beforeEach(() => { + envEncryption = injectr( + '../../dist/utils/env-encryption.js', + { + 'node:crypto': require('crypto') + }, + { + __dirname: __dirname, + Buffer: Buffer + } + ); + }); + + describe('when validating encryption key', () => { + it('should accept a valid 64-character hex key', () => { + const validKey = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const content = 'API_KEY=secret123\nDB_PASSWORD=password456'; + + expect(() => envEncryption.encrypt(content, validKey)).to.not.throw(); + }); + + it('should reject an invalid key (too short)', () => { + const invalidKey = '0123456789abcdef'; + const content = 'API_KEY=secret123'; + + expect(() => envEncryption.encrypt(content, invalidKey)).to.throw( + 'Encryption key must be 64 hexadecimal characters (32 bytes)' + ); + }); + + it('should reject an invalid key (non-hex characters)', () => { + const invalidKey = + 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'; + const content = 'API_KEY=secret123'; + + expect(() => envEncryption.encrypt(content, invalidKey)).to.throw( + 'Encryption key must be 64 hexadecimal characters (32 bytes)' + ); + }); + }); + + describe('isEncrypted', () => { + it('should return true for encrypted content', () => { + const encryptedContent = + 'OC_ENCRYPTED_V1:dGVzdGl2MTI=:dGVzdGF1dGh0YWcxMjM0NTY=:ZGF0YQ=='; + + expect(envEncryption.isEncrypted(encryptedContent)).to.be.true; + }); + + it('should return false for plaintext content', () => { + const plaintextContent = 'API_KEY=secret123\nDB_PASSWORD=password456'; + + expect(envEncryption.isEncrypted(plaintextContent)).to.be.false; + }); + }); + + describe('encrypt and decrypt', () => { + const validKey = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + it('should encrypt and decrypt content successfully', () => { + const originalContent = 'API_KEY=secret123\nDB_PASSWORD=password456'; + + const encrypted = envEncryption.encrypt(originalContent, validKey); + const decrypted = envEncryption.decrypt(encrypted, validKey); + + expect(decrypted).to.equal(originalContent); + }); + + it('should produce different ciphertext for same content (due to random IV)', () => { + const content = 'API_KEY=secret123'; + + const encrypted1 = envEncryption.encrypt(content, validKey); + const encrypted2 = envEncryption.encrypt(content, validKey); + + expect(encrypted1).to.not.equal(encrypted2); + }); + + it('should prepend OC_ENCRYPTED_V1 header to encrypted content', () => { + const content = 'API_KEY=secret123'; + + const encrypted = envEncryption.encrypt(content, validKey); + + expect(encrypted).to.match(/^OC_ENCRYPTED_V1:/); + }); + + it('should throw error when trying to decrypt with wrong key', () => { + const content = 'API_KEY=secret123'; + const key1 = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const key2 = + 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210'; + + const encrypted = envEncryption.encrypt(content, key1); + + expect(() => envEncryption.decrypt(encrypted, key2)).to.throw(); + }); + + it('should throw error when trying to decrypt plaintext', () => { + const plaintext = 'API_KEY=secret123'; + + expect(() => envEncryption.decrypt(plaintext, validKey)).to.throw( + 'Content is not encrypted or uses an unknown format' + ); + }); + }); +}); + From 7fd1cee5fd04858d2b85d04691433b4b5c2be636 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Thu, 16 Oct 2025 22:09:28 +0200 Subject: [PATCH 2/5] Validate --- .../validators/registry-configuration.ts | 7 ++++++ src/resources/index.ts | 2 ++ src/utils/env-encryption.ts | 23 ------------------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/registry/domain/validators/registry-configuration.ts b/src/registry/domain/validators/registry-configuration.ts index 75e7cf9d5..f52238d1d 100644 --- a/src/registry/domain/validators/registry-configuration.ts +++ b/src/registry/domain/validators/registry-configuration.ts @@ -1,5 +1,6 @@ import strings from '../../../resources'; import type { Config } from '../../../types'; +import * as envEncryption from '../../../utils/env-encryption'; import * as auth from '../authentication'; type ValidationResult = { isValid: true } | { isValid: false; message: string }; @@ -122,6 +123,12 @@ export default function registryConfiguration( } } + if (conf.envEncryptionKey) { + if (!/^[0-9a-fA-F]{64}$/.test(conf.envEncryptionKey)) { + return returnError(strings.errors.registry.ENV_ENCRYPTION_KEY_NOT_VALID); + } + } + if (conf.customHeadersToSkipOnWeakVersion) { if (!Array.isArray(conf.customHeadersToSkipOnWeakVersion)) { return returnError( diff --git a/src/resources/index.ts b/src/resources/index.ts index 456648f29..0bd7ddf30 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -61,6 +61,8 @@ export default { }, errors: { registry: { + ENV_ENCRYPTION_KEY_NOT_VALID: + 'Encryption key must be 64 hexadecimal characters (32 bytes)', BATCH_ROUTE_BODY_NOT_VALID: (message: string): string => `The request body is malformed: ${message}`, BATCH_ROUTE_BODY_NOT_VALID_CODE: 'POST_BODY_NOT_VALID', diff --git a/src/utils/env-encryption.ts b/src/utils/env-encryption.ts index 84a18a2db..410484525 100644 --- a/src/utils/env-encryption.ts +++ b/src/utils/env-encryption.ts @@ -4,25 +4,6 @@ const ENCRYPTION_VERSION = 'OC_ENCRYPTED_V1'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; // 12 bytes for GCM -/** - * Validates that the encryption key is properly formatted (32 bytes / 64 hex chars) - */ -function validateEncryptionKey(key: string): void { - if (!key || typeof key !== 'string') { - throw new Error('Encryption key must be a non-empty string'); - } - - // Remove any whitespace - const cleanKey = key.trim(); - - // Check if it's a valid hex string of 64 characters (32 bytes) - if (!/^[0-9a-fA-F]{64}$/.test(cleanKey)) { - throw new Error( - 'Encryption key must be 64 hexadecimal characters (32 bytes)' - ); - } -} - /** * Checks if content is encrypted by looking for the version header */ @@ -35,8 +16,6 @@ export function isEncrypted(content: string): boolean { * Returns format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} */ export function encrypt(content: string, key: string): string { - validateEncryptionKey(key); - // Generate random IV const iv = crypto.randomBytes(IV_LENGTH); @@ -59,8 +38,6 @@ export function encrypt(content: string, key: string): string { * Expects format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} */ export function decrypt(content: string, key: string): string { - validateEncryptionKey(key); - // Check for encrypted header if (!isEncrypted(content)) { throw new Error('Content is not encrypted or uses an unknown format'); From 6aa6924fc3cf019143c0878670d1b7a6cae00418 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Thu, 16 Oct 2025 22:57:10 +0200 Subject: [PATCH 3/5] remove import --- src/registry/domain/validators/registry-configuration.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registry/domain/validators/registry-configuration.ts b/src/registry/domain/validators/registry-configuration.ts index f52238d1d..73e2f508c 100644 --- a/src/registry/domain/validators/registry-configuration.ts +++ b/src/registry/domain/validators/registry-configuration.ts @@ -1,6 +1,5 @@ import strings from '../../../resources'; import type { Config } from '../../../types'; -import * as envEncryption from '../../../utils/env-encryption'; import * as auth from '../authentication'; type ValidationResult = { isValid: true } | { isValid: false; message: string }; From 07e28dc17e2561bc5bac6ae9d0a95bf685d93332 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Fri, 17 Oct 2025 09:09:00 +0200 Subject: [PATCH 4/5] Add support for keys --- .../validators/registry-configuration.ts | 13 +- src/types.ts | 18 ++- src/utils/env-encryption.ts | 120 ++++++++++++---- test/unit/utils-env-encryption.js | 130 +++++++++++++++++- 4 files changed, 251 insertions(+), 30 deletions(-) diff --git a/src/registry/domain/validators/registry-configuration.ts b/src/registry/domain/validators/registry-configuration.ts index 73e2f508c..cee47e706 100644 --- a/src/registry/domain/validators/registry-configuration.ts +++ b/src/registry/domain/validators/registry-configuration.ts @@ -1,5 +1,6 @@ import strings from '../../../resources'; import type { Config } from '../../../types'; +import * as envEncryption from '../../../utils/env-encryption'; import * as auth from '../authentication'; type ValidationResult = { isValid: true } | { isValid: false; message: string }; @@ -123,8 +124,16 @@ export default function registryConfiguration( } if (conf.envEncryptionKey) { - if (!/^[0-9a-fA-F]{64}$/.test(conf.envEncryptionKey)) { - return returnError(strings.errors.registry.ENV_ENCRYPTION_KEY_NOT_VALID); + const keys = Array.isArray(conf.envEncryptionKey) + ? conf.envEncryptionKey + : [conf.envEncryptionKey]; + + for (const key of keys) { + if (!envEncryption.validateEncryptionKey(key)) { + return returnError( + strings.errors.registry.ENV_ENCRYPTION_KEY_NOT_VALID + ); + } } } diff --git a/src/types.ts b/src/types.ts index 7c8d3198d..dc7fa3126 100644 --- a/src/types.ts +++ b/src/types.ts @@ -396,14 +396,24 @@ export interface Config { */ verbosity: number; /** - * Encryption key for securing component .env files in storage. - * Must be a 32-byte key represented as 64 hexadecimal characters. - * When set, .env files will be encrypted before storage and decrypted on retrieval. + * Encryption key(s) for securing component .env files in storage. + * Each key must be a 32-byte key represented as 64 hexadecimal characters. + * + * - **Single key (string)**: Used for both encryption and decryption + * - **Multiple keys (array)**: Most recent key at END of array + * + * Multiple keys enable secure key rotation: + * 1. Add new key at end: `[oldKey, newKey]` + * 2. New publishes use last key (newKey), but store which key index was used + * 3. Decryption tries the stored key index first, then falls back to others + * 4. Remove old keys once all components re-published + * * When not set, .env files are stored as plaintext (with a warning). * * @example "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + * @example ["old-key...", "new-key..."] // Key rotation - newest at end */ - envEncryptionKey?: string; + envEncryptionKey?: string | string[]; } type CompiledTemplate = (model: unknown) => string; diff --git a/src/utils/env-encryption.ts b/src/utils/env-encryption.ts index 410484525..355104276 100644 --- a/src/utils/env-encryption.ts +++ b/src/utils/env-encryption.ts @@ -2,7 +2,17 @@ import crypto from 'node:crypto'; const ENCRYPTION_VERSION = 'OC_ENCRYPTED_V1'; const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; // 12 bytes for GCM +const IV_BYTE_LENGTH = 12; // 12 bytes for GCM +const MAX_KEY_ATTEMPTS = 5; // Maximum number of keys to try during decryption + +/** + * Validates that the encryption key is properly formatted (32 bytes / 64 hex chars) + */ +export function validateEncryptionKey(key: string): boolean { + const cleanKey = key.trim(); + + return /^[0-9a-fA-F]{64}$/.test(cleanKey); +} /** * Checks if content is encrypted by looking for the version header @@ -13,14 +23,28 @@ export function isEncrypted(content: string): boolean { /** * Encrypts content using AES-256-GCM - * Returns format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} + * If array of keys provided, uses the LAST key (most recent) and stores its index + * Returns format: OC_ENCRYPTED_V1:{keyIndex}:{iv_base64}:{authTag_base64}:{encryptedData_base64} */ -export function encrypt(content: string, key: string): string { +export function encrypt(content: string, keys: string | string[]): string { + // Use last key if array (most recent), otherwise use the single key + const keyList = Array.isArray(keys) ? keys : [keys]; + const keyIndex = keyList.length - 1; + const primaryKey = keyList[keyIndex]; + + if (!validateEncryptionKey(primaryKey)) { + throw new Error('Invalid encryption key'); + } + // Generate random IV - const iv = crypto.randomBytes(IV_LENGTH); + const iv = crypto.randomBytes(IV_BYTE_LENGTH); // Create cipher - const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv); + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(primaryKey, 'hex'), + iv + ); // Encrypt the content let encrypted = cipher.update(content, 'utf8', 'base64'); @@ -29,15 +53,16 @@ export function encrypt(content: string, key: string): string { // Get auth tag const authTag = cipher.getAuthTag(); - // Combine into our format - return `${ENCRYPTION_VERSION}:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; + // Combine into our format with key index + return `${ENCRYPTION_VERSION}:${keyIndex}:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; } /** * Decrypts content that was encrypted with the encrypt function - * Expects format: OC_ENCRYPTED_V1:{iv_base64}:{authTag_base64}:{encryptedData_base64} + * Tries keys intelligently based on stored key index, with fallback to other keys + * Expects format: OC_ENCRYPTED_V1:{keyIndex}:{iv_base64}:{authTag_base64}:{encryptedData_base64} */ -export function decrypt(content: string, key: string): string { +export function decrypt(content: string, keys: string | string[]): string { // Check for encrypted header if (!isEncrypted(content)) { throw new Error('Content is not encrypted or uses an unknown format'); @@ -45,11 +70,12 @@ export function decrypt(content: string, key: string): string { // Parse the encrypted content const parts = content.split(':'); - if (parts.length !== 4) { + if (parts.length !== 5) { throw new Error('Invalid encrypted content format'); } - const [version, ivBase64, authTagBase64, encryptedData] = parts; + const [version, storedKeyIndex, ivBase64, authTagBase64, encryptedData] = + parts; if (version !== ENCRYPTION_VERSION) { throw new Error(`Unsupported encryption version: ${version}`); @@ -58,20 +84,68 @@ export function decrypt(content: string, key: string): string { // Decode components const iv = Buffer.from(ivBase64, 'base64'); const authTag = Buffer.from(authTagBase64, 'base64'); + const keyIndex = parseInt(storedKeyIndex, 10); - // Create decipher - const decipher = crypto.createDecipheriv( - ALGORITHM, - Buffer.from(key, 'hex'), - iv - ); + // Convert to array for consistent handling + const keyList = Array.isArray(keys) ? keys : [keys]; + + // Build smart key attempt order + const keysToTry: string[] = []; + const triedIndices = new Set(); + + // 1. Try the key at stored index first (most likely to work) + if (keyIndex >= 0 && keyIndex < keyList.length) { + keysToTry.push(keyList[keyIndex]); + triedIndices.add(keyIndex); + } - // Set auth tag - decipher.setAuthTag(authTag); + // 2. Try the last key (current/most recent) if not already tried + const lastIndex = keyList.length - 1; + if (!triedIndices.has(lastIndex)) { + keysToTry.push(keyList[lastIndex]); + triedIndices.add(lastIndex); + } - // Decrypt - let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); - decrypted += decipher.final('utf8'); + // 3. Try remaining keys (newest to oldest), respecting MAX_KEY_ATTEMPTS limit + for ( + let i = keyList.length - 1; + i >= 0 && keysToTry.length < MAX_KEY_ATTEMPTS; + i-- + ) { + if (!triedIndices.has(i)) { + keysToTry.push(keyList[i]); + triedIndices.add(i); + } + } - return decrypted; + // Try each key + let lastError: Error | null = null; + + for (const key of keysToTry) { + try { + // Create decipher + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(key, 'hex'), + iv + ); + + // Set auth tag + decipher.setAuthTag(authTag); + + // Decrypt + let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (err) { + // This key didn't work, try next one + lastError = err as Error; + } + } + + // None of the keys worked + throw new Error( + `Failed to decrypt: tried ${keysToTry.length} key(s). ${lastError?.message || 'Unknown error'}` + ); } diff --git a/test/unit/utils-env-encryption.js b/test/unit/utils-env-encryption.js index 8f30f5285..f0ddbaa30 100644 --- a/test/unit/utils-env-encryption.js +++ b/test/unit/utils-env-encryption.js @@ -49,7 +49,7 @@ describe('utils : env-encryption', () => { describe('isEncrypted', () => { it('should return true for encrypted content', () => { const encryptedContent = - 'OC_ENCRYPTED_V1:dGVzdGl2MTI=:dGVzdGF1dGh0YWcxMjM0NTY=:ZGF0YQ=='; + 'OC_ENCRYPTED_V1:0:dGVzdGl2MTI=:dGVzdGF1dGh0YWcxMjM0NTY=:ZGF0YQ=='; expect(envEncryption.isEncrypted(encryptedContent)).to.be.true; }); @@ -110,6 +110,134 @@ describe('utils : env-encryption', () => { 'Content is not encrypted or uses an unknown format' ); }); + + it('should include key index in encrypted format', () => { + const content = 'API_KEY=secret123'; + + const encrypted = envEncryption.encrypt(content, validKey); + const parts = encrypted.split(':'); + + expect(parts.length).to.equal(5); + expect(parts[0]).to.equal('OC_ENCRYPTED_V1'); + expect(parts[1]).to.equal('0'); // Single key has index 0 + }); + }); + + describe('key rotation with indexed keys', () => { + const key1 = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const key2 = + 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210'; + const key3 = + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + + it('should encrypt with last key when array provided', () => { + const content = 'API_KEY=secret123'; + const keys = [key1, key2]; + + const encrypted = envEncryption.encrypt(content, keys); + const parts = encrypted.split(':'); + + expect(parts[1]).to.equal('1'); // Index 1 (last key) + + // Should decrypt with just the last key + const decrypted = envEncryption.decrypt(encrypted, key2); + expect(decrypted).to.equal(content); + }); + + it('should decrypt using stored key index', () => { + const content = 'API_KEY=secret123'; + const keys = [key1, key2, key3]; + + // Encrypt with key3 (last in array, index 2) + const encrypted = envEncryption.encrypt(content, keys); + + // Should decrypt successfully using key at stored index + const decrypted = envEncryption.decrypt(encrypted, keys); + expect(decrypted).to.equal(content); + }); + + it('should fallback to last key if stored index not in array', () => { + const content = 'API_KEY=secret123'; + + // Encrypt with key2 (last in 2-element array, index 1) + const encrypted = envEncryption.encrypt(content, [key1, key2]); + + // Now decrypt with just key2 as single key (stored index 1 doesn't exist) + // Should fallback to trying the only available key + const decrypted = envEncryption.decrypt(encrypted, key2); + expect(decrypted).to.equal(content); + }); + + it('should try remaining keys if stored index key fails', () => { + const content = 'API_KEY=secret123'; + + // Encrypt with key2 (index 1 in array) + const encrypted = envEncryption.encrypt(content, [key1, key2]); + + // Try to decrypt with different keys where stored index has wrong key + // Should fallback and try key2 (which is last in new array) + const decrypted = envEncryption.decrypt(encrypted, [key3, key2]); + expect(decrypted).to.equal(content); + }); + + it('should respect MAX_KEY_ATTEMPTS limit', () => { + const content = 'API_KEY=secret123'; + const manyKeys = [key1, key2, key3, key1, key2, key3, key1, key2]; + + // Encrypt with last key + const encrypted = envEncryption.encrypt(content, manyKeys); + + // Should successfully decrypt even with many keys + const decrypted = envEncryption.decrypt(encrypted, manyKeys); + expect(decrypted).to.equal(content); + }); + + it('should throw error if no keys work', () => { + const content = 'API_KEY=secret123'; + + // Encrypt with key1 + const encrypted = envEncryption.encrypt(content, key1); + + // Try to decrypt with different keys + expect(() => envEncryption.decrypt(encrypted, [key2, key3])).to.throw( + 'Failed to decrypt' + ); + }); + + it('should handle key rotation workflow', () => { + const content = 'API_KEY=secret123'; + + // Step 1: Start with single key + const encrypted1 = envEncryption.encrypt(content, key1); + + // Step 2: Rotate to new key (add at end) + const keys = [key1, key2]; + const encrypted2 = envEncryption.encrypt(content, keys); + + // Step 3: Both should decrypt with key array + const decrypted1 = envEncryption.decrypt(encrypted1, keys); + const decrypted2 = envEncryption.decrypt(encrypted2, keys); + + expect(decrypted1).to.equal(content); + expect(decrypted2).to.equal(content); + }); + + it('should use key index for efficient decryption', () => { + const content = 'API_KEY=secret123'; + + // Encrypt with middle key in array + const keys = [key1, key2, key3]; + const encrypted = envEncryption.encrypt(content, keys); + + // The stored index should be 2 (last key) + const parts = encrypted.split(':'); + expect(parts[1]).to.equal('2'); + + // Should decrypt successfully + const decrypted = envEncryption.decrypt(encrypted, keys); + expect(decrypted).to.equal(content); + }); }); }); From 285bd41cfe643adcfde1453485d59ebe6c715754 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Fri, 17 Oct 2025 09:11:34 +0200 Subject: [PATCH 5/5] change key err msg --- src/utils/env-encryption.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/env-encryption.ts b/src/utils/env-encryption.ts index 355104276..05ec4e2ab 100644 --- a/src/utils/env-encryption.ts +++ b/src/utils/env-encryption.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import strings from '../resources'; const ENCRYPTION_VERSION = 'OC_ENCRYPTED_V1'; const ALGORITHM = 'aes-256-gcm'; @@ -33,7 +34,7 @@ export function encrypt(content: string, keys: string | string[]): string { const primaryKey = keyList[keyIndex]; if (!validateEncryptionKey(primaryKey)) { - throw new Error('Invalid encryption key'); + throw new Error(strings.errors.registry.ENV_ENCRYPTION_KEY_NOT_VALID); } // Generate random IV