Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions src/registry/domain/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}`
Expand Down
15 changes: 15 additions & 0 deletions src/registry/domain/validators/registry-configuration.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -122,6 +123,20 @@ export default function registryConfiguration(
}
}

if (conf.envEncryptionKey) {
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
);
}
}
}

if (conf.customHeadersToSkipOnWeakVersion) {
if (!Array.isArray(conf.customHeadersToSkipOnWeakVersion)) {
return returnError(
Expand Down
2 changes: 2 additions & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,25 @@ export interface Config<T = any> {
* @default 0
*/
verbosity: number;
/**
* 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 | string[];
}

type CompiledTemplate = (model: unknown) => string;
Expand Down
152 changes: 152 additions & 0 deletions src/utils/env-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import crypto from 'node:crypto';
import strings from '../resources';

const ENCRYPTION_VERSION = 'OC_ENCRYPTED_V1';
const ALGORITHM = 'aes-256-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
*/
export function isEncrypted(content: string): boolean {
return content.startsWith(`${ENCRYPTION_VERSION}:`);
}

/**
* Encrypts content using AES-256-GCM
* 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, 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(strings.errors.registry.ENV_ENCRYPTION_KEY_NOT_VALID);
}

// Generate random IV
const iv = crypto.randomBytes(IV_BYTE_LENGTH);

// Create cipher
const cipher = crypto.createCipheriv(
ALGORITHM,
Buffer.from(primaryKey, '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 with key index
return `${ENCRYPTION_VERSION}:${keyIndex}:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}

/**
* Decrypts content that was encrypted with the encrypt function
* 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, keys: string | string[]): string {
// 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 !== 5) {
throw new Error('Invalid encrypted content format');
}

const [version, storedKeyIndex, 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');
const keyIndex = parseInt(storedKeyIndex, 10);

// 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<number>();

// 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);
}

// 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);
}

// 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);
}
}

// 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'}`
);
}
5 changes: 4 additions & 1 deletion test/unit/registry-domain-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading