@aifabrix/builder 2.2.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/secrets.js CHANGED
@@ -32,6 +32,7 @@ const {
32
32
  buildHostnameToServiceMap,
33
33
  resolveUrlPort
34
34
  } = require('./utils/secrets-utils');
35
+ const { decryptSecret, isEncrypted } = require('./utils/secrets-encryption');
35
36
 
36
37
  /**
37
38
  * Loads environment configuration for docker/local context
@@ -43,16 +44,59 @@ function loadEnvConfig() {
43
44
  return yaml.load(content);
44
45
  }
45
46
 
47
+ /**
48
+ * Decrypts encrypted values in secrets object
49
+ * Checks for secure:// prefix and decrypts using encryption key from config
50
+ *
51
+ * @async
52
+ * @function decryptSecretsObject
53
+ * @param {Object} secrets - Secrets object with potentially encrypted values
54
+ * @returns {Promise<Object>} Secrets object with decrypted values
55
+ * @throws {Error} If decryption fails or encryption key is missing
56
+ */
57
+ async function decryptSecretsObject(secrets) {
58
+ if (!secrets || typeof secrets !== 'object') {
59
+ return secrets;
60
+ }
61
+
62
+ const encryptionKey = await config.getSecretsEncryptionKey();
63
+ if (!encryptionKey) {
64
+ // No encryption key set, check if any values are encrypted
65
+ const hasEncrypted = Object.values(secrets).some(value => isEncrypted(value));
66
+ if (hasEncrypted) {
67
+ throw new Error('Encrypted secrets found but no encryption key configured. Run "aifabrix secure --secrets-encryption <key>" to set encryption key.');
68
+ }
69
+ // No encrypted values, return as-is
70
+ return secrets;
71
+ }
72
+
73
+ const decryptedSecrets = {};
74
+ for (const [key, value] of Object.entries(secrets)) {
75
+ if (isEncrypted(value)) {
76
+ try {
77
+ decryptedSecrets[key] = decryptSecret(value, encryptionKey);
78
+ } catch (error) {
79
+ throw new Error(`Failed to decrypt secret '${key}': ${error.message}`);
80
+ }
81
+ } else {
82
+ decryptedSecrets[key] = value;
83
+ }
84
+ }
85
+
86
+ return decryptedSecrets;
87
+ }
88
+
46
89
  /**
47
90
  * Loads secrets with cascading lookup
48
91
  * Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
49
92
  * User's file takes priority, then falls back to build.secrets from variables.yaml
93
+ * Automatically decrypts values with secure:// prefix
50
94
  *
51
95
  * @async
52
96
  * @function loadSecrets
53
97
  * @param {string} [secretsPath] - Path to secrets file (optional, for explicit override)
54
98
  * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
55
- * @returns {Promise<Object>} Loaded secrets object
99
+ * @returns {Promise<Object>} Loaded secrets object with decrypted values
56
100
  * @throws {Error} If no secrets file found and no fallback available
57
101
  *
58
102
  * @example
@@ -60,6 +104,8 @@ function loadEnvConfig() {
60
104
  * // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
61
105
  */
62
106
  async function loadSecrets(secretsPath, appName) {
107
+ let secrets;
108
+
63
109
  // If explicit path provided, use it (backward compatibility)
64
110
  if (secretsPath) {
65
111
  const resolvedPath = resolveSecretsPath(secretsPath);
@@ -68,34 +114,35 @@ async function loadSecrets(secretsPath, appName) {
68
114
  }
69
115
 
70
116
  const content = fs.readFileSync(resolvedPath, 'utf8');
71
- const secrets = yaml.load(content);
117
+ secrets = yaml.load(content);
72
118
 
73
119
  if (!secrets || typeof secrets !== 'object') {
74
120
  throw new Error(`Invalid secrets file format: ${resolvedPath}`);
75
121
  }
122
+ } else {
123
+ // Cascading lookup: user's file first
124
+ let mergedSecrets = loadUserSecrets();
76
125
 
77
- return secrets;
78
- }
79
-
80
- // Cascading lookup: user's file first
81
- let mergedSecrets = loadUserSecrets();
126
+ // Then check build.secrets from variables.yaml if appName provided
127
+ if (appName) {
128
+ mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
129
+ }
82
130
 
83
- // Then check build.secrets from variables.yaml if appName provided
84
- if (appName) {
85
- mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
86
- }
131
+ // If still no secrets found, try default location
132
+ if (Object.keys(mergedSecrets).length === 0) {
133
+ mergedSecrets = loadDefaultSecrets();
134
+ }
87
135
 
88
- // If still no secrets found, try default location
89
- if (Object.keys(mergedSecrets).length === 0) {
90
- mergedSecrets = loadDefaultSecrets();
91
- }
136
+ // If still empty, throw error
137
+ if (Object.keys(mergedSecrets).length === 0) {
138
+ throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
139
+ }
92
140
 
93
- // If still empty, throw error
94
- if (Object.keys(mergedSecrets).length === 0) {
95
- throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
141
+ secrets = mergedSecrets;
96
142
  }
97
143
 
98
- return mergedSecrets;
144
+ // Decrypt encrypted values
145
+ return await decryptSecretsObject(secrets);
99
146
  }
100
147
 
101
148
  /**
@@ -110,6 +157,7 @@ async function loadSecrets(secretsPath, appName) {
110
157
  * @param {Object|string|null} [secretsFilePaths] - Paths object with userPath and buildPath, or string path (for backward compatibility)
111
158
  * @param {string} [secretsFilePaths.userPath] - User's secrets file path
112
159
  * @param {string|null} [secretsFilePaths.buildPath] - App's build.secrets file path (if configured)
160
+ * @param {string} [appName] - Application name (optional, for error messages)
113
161
  * @returns {Promise<string>} Resolved environment content
114
162
  * @throws {Error} If kv:// reference cannot be resolved
115
163
  *
@@ -117,7 +165,7 @@ async function loadSecrets(secretsPath, appName) {
117
165
  * const resolved = await resolveKvReferences(template, secrets, 'local');
118
166
  * // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
119
167
  */
120
- async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null) {
168
+ async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null, appName = null) {
121
169
  const envConfig = loadEnvConfig();
122
170
  const envVars = envConfig.environments[environment] || envConfig.environments.local;
123
171
 
@@ -152,7 +200,8 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
152
200
  fileInfo = `\n\nSecrets file location: ${paths.join(' and ')}`;
153
201
  }
154
202
  }
155
- throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}`);
203
+ const resolveCommand = appName ? `aifabrix resolve ${appName}` : 'aifabrix resolve <app-name>';
204
+ throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}\n\nRun "${resolveCommand}" to generate missing secrets.`);
156
205
  }
157
206
 
158
207
  // Now replace kv:// references, and handle ${VAR} inside the secret values
@@ -290,7 +339,7 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
290
339
  const template = loadEnvTemplate(templatePath);
291
340
 
292
341
  // Resolve secrets paths to show in error messages (use actual paths that loadSecrets would use)
293
- const secretsPaths = getActualSecretsPath(secretsPath, appName);
342
+ const secretsPaths = await getActualSecretsPath(secretsPath, appName);
294
343
 
295
344
  if (force) {
296
345
  // Use userPath for generating missing secrets (priority file)
@@ -298,7 +347,7 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
298
347
  }
299
348
 
300
349
  const secrets = await loadSecrets(secretsPath, appName);
301
- let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths);
350
+ let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths, appName);
302
351
 
303
352
  // Resolve service ports in URLs for docker environment
304
353
  if (environment === 'docker') {
@@ -308,7 +357,9 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
308
357
  // For local environment, update infrastructure ports to use dev-specific ports
309
358
  if (environment === 'local') {
310
359
  const devId = await config.getDeveloperId();
311
- const ports = devConfig.getDevPorts(devId);
360
+ // Convert string developer ID to number for getDevPorts
361
+ const devIdNum = parseInt(devId, 10);
362
+ const ports = devConfig.getDevPorts(devIdNum);
312
363
 
313
364
  // Update DATABASE_PORT if present
314
365
  resolved = resolved.replace(/^DATABASE_PORT\s*=\s*.*$/m, `DATABASE_PORT=${ports.postgres}`);
package/lib/templates.js CHANGED
@@ -49,7 +49,7 @@ function generateVariablesYaml(appName, config) {
49
49
  build: {
50
50
  language: config.language || 'typescript',
51
51
  envOutputPath: null,
52
- context: `../${appName}`,
52
+ context: null, // Defaults to dev directory in build process
53
53
  dockerfile: '',
54
54
  secrets: null
55
55
  },
@@ -22,7 +22,7 @@ const os = require('os');
22
22
  * @async
23
23
  * @function copyBuilderToDevDirectory
24
24
  * @param {string} appName - Application name
25
- * @param {number} developerId - Developer ID
25
+ * @param {number|string} developerId - Developer ID
26
26
  * @returns {Promise<string>} Path to developer-specific directory
27
27
  * @throws {Error} If copying fails
28
28
  *
@@ -39,7 +39,8 @@ async function copyBuilderToDevDirectory(appName, developerId) {
39
39
  }
40
40
 
41
41
  // Get base directory (applications or applications-dev-{id})
42
- const baseDir = developerId === 0
42
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
43
+ const baseDir = idNum === 0
43
44
  ? path.join(os.homedir(), '.aifabrix', 'applications')
44
45
  : path.join(os.homedir(), '.aifabrix', `applications-dev-${developerId}`);
45
46
 
@@ -64,7 +65,7 @@ async function copyBuilderToDevDirectory(appName, developerId) {
64
65
  await fs.mkdir(devDir, { recursive: true });
65
66
 
66
67
  // Copy files based on developer ID
67
- if (developerId === 0) {
68
+ if (idNum === 0) {
68
69
  // Dev 0: Copy contents from builder/{appName}/ directly to applications/
69
70
  await copyDirectory(builderPath, devDir);
70
71
  } else {
@@ -112,11 +113,12 @@ async function copyDirectory(sourceDir, targetDir) {
112
113
  /**
113
114
  * Gets developer-specific directory path for an application
114
115
  * @param {string} appName - Application name
115
- * @param {number} developerId - Developer ID
116
+ * @param {number|string} developerId - Developer ID
116
117
  * @returns {string} Path to developer-specific directory
117
118
  */
118
119
  function getDevDirectory(appName, developerId) {
119
- if (developerId === 0) {
120
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
121
+ if (idNum === 0) {
120
122
  // Dev 0: all apps go directly to applications/ (no subdirectory)
121
123
  return path.join(os.homedir(), '.aifabrix', 'applications');
122
124
  }
@@ -125,6 +127,23 @@ function getDevDirectory(appName, developerId) {
125
127
 
126
128
  }
127
129
 
130
+ /**
131
+ * Copies app source files from apps directory to dev directory
132
+ * Used when old context format is detected to ensure source files are available
133
+ * @async
134
+ * @param {string} appsSourcePath - Path to apps/{appName} directory
135
+ * @param {string} devDir - Target dev directory
136
+ * @throws {Error} If copying fails
137
+ */
138
+ async function copyAppSourceFiles(appsSourcePath, devDir) {
139
+ if (!fsSync.existsSync(appsSourcePath)) {
140
+ return; // Nothing to copy
141
+ }
142
+
143
+ // Copy all files from apps directory to dev directory
144
+ await copyDirectory(appsSourcePath, devDir);
145
+ }
146
+
128
147
  /**
129
148
  * Checks if developer-specific directory exists
130
149
  * @param {string} appName - Application name
@@ -138,6 +157,7 @@ function devDirectoryExists(appName, developerId) {
138
157
 
139
158
  module.exports = {
140
159
  copyBuilderToDevDirectory,
160
+ copyAppSourceFiles,
141
161
  getDevDirectory,
142
162
  devDirectoryExists
143
163
  };
@@ -60,11 +60,12 @@ function formatError(error) {
60
60
  } else if (errorMsg.includes('permission')) {
61
61
  messages.push(' Permission denied.');
62
62
  messages.push(' Make sure you have the necessary permissions to run Docker commands.');
63
- } else if (errorMsg.includes('Azure CLI') || errorMsg.includes('az --version')) {
64
- messages.push(' Azure CLI is not installed.');
63
+ } else if (errorMsg.includes('Azure CLI is not installed') || errorMsg.includes('az --version failed') || (errorMsg.includes('az') && errorMsg.includes('failed'))) {
64
+ // Specific error for missing Azure CLI installation or Azure CLI command failures
65
+ messages.push(' Azure CLI is not installed or not working properly.');
65
66
  messages.push(' Install from: https://docs.microsoft.com/cli/azure/install-azure-cli');
66
67
  messages.push(' Run: az login');
67
- } else if (errorMsg.includes('authenticate') || errorMsg.includes('ACR')) {
68
+ } else if (errorMsg.includes('authenticate') || errorMsg.includes('ACR') || errorMsg.includes('Authentication required')) {
68
69
  messages.push(' Azure Container Registry authentication failed.');
69
70
  messages.push(' Run: az acr login --name <registry-name>');
70
71
  messages.push(' Or login to Azure: az login');
@@ -74,6 +75,30 @@ function formatError(error) {
74
75
  } else if (errorMsg.includes('Registry URL is required')) {
75
76
  messages.push(' Registry URL is required.');
76
77
  messages.push(' Provide via --registry flag or configure in variables.yaml under image.registry');
78
+ } else if (errorMsg.includes('Missing secrets')) {
79
+ // Extract the missing secrets list and file info from the error message
80
+ const missingSecretsMatch = errorMsg.match(/Missing secrets: ([^\n]+)/);
81
+ const fileInfoMatch = errorMsg.match(/Secrets file location: ([^\n]+)/);
82
+ const resolveMatch = errorMsg.match(/Run "aifabrix resolve ([^"]+)"/);
83
+
84
+ if (missingSecretsMatch) {
85
+ messages.push(` Missing secrets: ${missingSecretsMatch[1]}`);
86
+ } else {
87
+ messages.push(' Missing secrets in secrets file.');
88
+ }
89
+
90
+ if (fileInfoMatch) {
91
+ messages.push(` Secrets file location: ${fileInfoMatch[1]}`);
92
+ }
93
+
94
+ // Always show resolve command suggestion
95
+ if (resolveMatch) {
96
+ // Extract app name from error message if available
97
+ messages.push(` Run: aifabrix resolve ${resolveMatch[1]} to generate missing secrets.`);
98
+ } else {
99
+ // Generic suggestion if app name not in error message
100
+ messages.push(' Run: aifabrix resolve <app-name> to generate missing secrets.');
101
+ }
77
102
  } else if (errorMsg.includes('Deployment failed after')) {
78
103
  // Handle deployment retry errors - extract the actual error message
79
104
  const match = errorMsg.match(/Deployment failed after \d+ attempts: (.+)/);
@@ -307,12 +307,13 @@ async function generateDockerCompose(appName, appConfig, options) {
307
307
 
308
308
  // Get developer ID and network name
309
309
  const devId = await config.getDeveloperId();
310
+ const idNum = typeof devId === 'string' ? parseInt(devId, 10) : devId;
310
311
  // Dev 0: infra-aifabrix-network (no dev-0 suffix)
311
312
  // Dev > 0: infra-dev{id}-aifabrix-network
312
- const networkName = devId === 0 ? 'infra-aifabrix-network' : `infra-dev${devId}-aifabrix-network`;
313
+ const networkName = idNum === 0 ? 'infra-aifabrix-network' : `infra-dev${devId}-aifabrix-network`;
313
314
  // Dev 0: aifabrix-{appName} (no dev-0 suffix)
314
315
  // Dev > 0: aifabrix-dev{id}-{appName}
315
- const containerName = devId === 0 ? `aifabrix-${appName}` : `aifabrix-dev${devId}-${appName}`;
316
+ const containerName = idNum === 0 ? `aifabrix-${appName}` : `aifabrix-dev${devId}-${appName}`;
316
317
 
317
318
  const serviceConfig = buildServiceConfig(appName, appConfig, port);
318
319
  const volumesConfig = buildVolumesConfig(appName);
@@ -323,9 +324,21 @@ async function generateDockerCompose(appName, appConfig, options) {
323
324
  const envFilePath = path.join(devDir, '.env');
324
325
  const envFileAbsolutePath = envFilePath.replace(/\\/g, '/'); // Use forward slashes for Docker
325
326
 
326
- // Read database passwords from .env file
327
+ // Read database passwords from .env file only if database is required
327
328
  const databases = networksConfig.databases || [];
328
- const databasePasswords = await readDatabasePasswords(envFilePath, databases, appName);
329
+ const requiresDatabase = serviceConfig.requiresDatabase || false;
330
+ let databasePasswords;
331
+
332
+ if (requiresDatabase || databases.length > 0) {
333
+ // Only read passwords if database is actually required
334
+ databasePasswords = await readDatabasePasswords(envFilePath, databases, appName);
335
+ } else {
336
+ // Return empty passwords when database is not required
337
+ databasePasswords = {
338
+ map: {},
339
+ array: []
340
+ };
341
+ }
329
342
 
330
343
  const templateData = {
331
344
  ...serviceConfig,
@@ -27,7 +27,7 @@ const BASE_PORTS = {
27
27
  * Developer ID: 0 = default infra (base ports), > 0 = developer-specific (offset ports)
28
28
  *
29
29
  * @function getDevPorts
30
- * @param {number} developerId - Developer ID (0 = default infra, 1, 2, 3, etc. = developer-specific)
30
+ * @param {number} developerId - Developer ID (0 = default infra, 1, 2, 3, etc. = developer-specific). Must be a number.
31
31
  * @returns {Object} Object with calculated ports for all services
32
32
  *
33
33
  * @example
@@ -37,27 +37,28 @@ const BASE_PORTS = {
37
37
  * // Returns: { app: 3100, postgres: 5532, redis: 6479, pgadmin: 5150, redisCommander: 8181 }
38
38
  */
39
39
  function getDevPorts(developerId) {
40
- // Validate type first - must be a number
40
+ // Only accept numbers, reject strings and other types
41
41
  if (typeof developerId !== 'number') {
42
42
  throw new Error('Developer ID must be a positive number');
43
43
  }
44
44
 
45
- // Handle NaN, undefined, null - throw error (don't default)
46
- if (isNaN(developerId) || developerId === undefined || developerId === null) {
45
+ // Handle invalids
46
+ if (developerId === undefined || developerId === null || Number.isNaN(developerId)) {
47
47
  throw new Error('Developer ID must be a positive number');
48
48
  }
49
-
50
49
  if (developerId < 0 || !Number.isInteger(developerId)) {
51
50
  throw new Error('Developer ID must be a positive number');
52
51
  }
53
52
 
53
+ const idNum = developerId;
54
+
54
55
  // Developer ID 0 = default infra (base ports, no offset)
55
- if (developerId === 0) {
56
+ if (idNum === 0) {
56
57
  return { ...BASE_PORTS };
57
58
  }
58
59
 
59
60
  // Developer ID > 0 = developer-specific (add offset)
60
- const offset = developerId * 100;
61
+ const offset = idNum * 100;
61
62
 
62
63
  return {
63
64
  app: BASE_PORTS.app + offset,
@@ -78,6 +78,30 @@ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
78
78
  spinner: 'dots'
79
79
  }).start();
80
80
 
81
+ // Ensure paths are absolute and normalized
82
+ const fsSync = require('fs');
83
+ const path = require('path');
84
+
85
+ dockerfilePath = path.resolve(dockerfilePath);
86
+ contextPath = path.resolve(contextPath);
87
+
88
+ // Validate paths exist (skip in test environments)
89
+ const isTestEnv = process.env.NODE_ENV === 'test' ||
90
+ process.env.JEST_WORKER_ID !== undefined ||
91
+ typeof jest !== 'undefined';
92
+
93
+ if (!isTestEnv) {
94
+ if (!fsSync.existsSync(dockerfilePath)) {
95
+ spinner.fail('Build failed');
96
+ throw new Error(`Dockerfile not found: ${dockerfilePath}`);
97
+ }
98
+
99
+ if (!fsSync.existsSync(contextPath)) {
100
+ spinner.fail('Build failed');
101
+ throw new Error(`Build context path does not exist: ${contextPath}`);
102
+ }
103
+ }
104
+
81
105
  return new Promise((resolve, reject) => {
82
106
  // Use spawn for streaming output
83
107
  const dockerProcess = spawn('docker', [
@@ -20,14 +20,15 @@ const execAsync = promisify(exec);
20
20
  * @private
21
21
  * @async
22
22
  * @param {string} serviceName - Service name
23
- * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
23
+ * @param {number|string} [devId] - Developer ID (optional, will be loaded from config if not provided)
24
24
  * @returns {Promise<string|null>} Container name or null if not found
25
25
  */
26
26
  async function findContainer(serviceName, devId = null) {
27
27
  try {
28
28
  const developerId = devId || await config.getDeveloperId();
29
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
29
30
  // Dev 0: aifabrix-{serviceName}, Dev > 0: aifabrix-dev{id}-{serviceName}
30
- const containerNamePattern = developerId === 0
31
+ const containerNamePattern = idNum === 0
31
32
  ? `aifabrix-${serviceName}`
32
33
  : `aifabrix-dev${developerId}-${serviceName}`;
33
34
  let { stdout } = await execAsync(`docker ps --filter "name=${containerNamePattern}" --format "{{.Names}}"`);
@@ -0,0 +1,203 @@
1
+ /**
2
+ * AI Fabrix Builder Secrets Encryption Utilities
3
+ *
4
+ * This module provides encryption and decryption functions for secrets
5
+ * using AES-256-GCM algorithm for ISO 27001 compliance.
6
+ *
7
+ * @fileoverview Secrets encryption utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+
14
+ const ALGORITHM = 'aes-256-gcm';
15
+ const IV_LENGTH = 12; // 96 bits for GCM
16
+ const AUTH_TAG_LENGTH = 16; // 128 bits
17
+ const KEY_LENGTH = 32; // 256 bits for AES-256
18
+
19
+ /**
20
+ * Validates encryption key format
21
+ * Key must be 32 bytes (256 bits) for AES-256
22
+ * Accepts hex string (64 chars) or base64 string (44 chars)
23
+ *
24
+ * @function validateEncryptionKey
25
+ * @param {string} key - Encryption key to validate
26
+ * @returns {boolean} True if key is valid
27
+ * @throws {Error} If key format is invalid
28
+ */
29
+ function validateEncryptionKey(key) {
30
+ if (!key || typeof key !== 'string') {
31
+ throw new Error('Encryption key is required and must be a string');
32
+ }
33
+
34
+ // Try to parse as hex (64 characters = 32 bytes)
35
+ if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
36
+ return true;
37
+ }
38
+
39
+ // Try to parse as base64 (44 characters = 32 bytes)
40
+ if (key.length === 44) {
41
+ try {
42
+ const buffer = Buffer.from(key, 'base64');
43
+ if (buffer.length === KEY_LENGTH) {
44
+ return true;
45
+ }
46
+ } catch (error) {
47
+ // Not valid base64
48
+ }
49
+ }
50
+
51
+ throw new Error(`Encryption key must be 32 bytes (64 hex characters or 44 base64 characters). Got ${key.length} characters`);
52
+ }
53
+
54
+ /**
55
+ * Normalizes encryption key to Buffer
56
+ * Converts hex or base64 string to 32-byte buffer
57
+ *
58
+ * @function normalizeKey
59
+ * @param {string} key - Encryption key (hex or base64)
60
+ * @returns {Buffer} 32-byte key buffer
61
+ */
62
+ function normalizeKey(key) {
63
+ validateEncryptionKey(key);
64
+
65
+ // Try hex first (64 characters)
66
+ if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
67
+ return Buffer.from(key, 'hex');
68
+ }
69
+
70
+ // Try base64 (44 characters)
71
+ if (key.length === 44) {
72
+ const buffer = Buffer.from(key, 'base64');
73
+ if (buffer.length === KEY_LENGTH) {
74
+ return buffer;
75
+ }
76
+ }
77
+
78
+ throw new Error('Invalid encryption key format');
79
+ }
80
+
81
+ /**
82
+ * Encrypts a secret value using AES-256-GCM
83
+ * Returns encrypted value in format: secure://<iv>:<ciphertext>:<authTag>
84
+ * All components are base64 encoded
85
+ *
86
+ * @function encryptSecret
87
+ * @param {string} value - Plaintext secret value to encrypt
88
+ * @param {string} key - Encryption key (hex or base64, 32 bytes)
89
+ * @returns {string} Encrypted value with secure:// prefix
90
+ * @throws {Error} If encryption fails or key is invalid
91
+ *
92
+ * @example
93
+ * const encrypted = encryptSecret('my-secret', 'a1b2c3...');
94
+ * // Returns: 'secure://<iv>:<ciphertext>:<authTag>'
95
+ */
96
+ function encryptSecret(value, key) {
97
+ if (typeof value !== 'string') {
98
+ throw new Error('Value is required and must be a string');
99
+ }
100
+
101
+ const keyBuffer = normalizeKey(key);
102
+
103
+ // Generate random IV
104
+ const iv = crypto.randomBytes(IV_LENGTH);
105
+
106
+ // Create cipher
107
+ const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
108
+
109
+ // Encrypt
110
+ let ciphertext = cipher.update(value, 'utf8', 'base64');
111
+ ciphertext += cipher.final('base64');
112
+
113
+ // Get authentication tag
114
+ const authTag = cipher.getAuthTag();
115
+
116
+ // Format: secure://<iv>:<ciphertext>:<authTag>
117
+ return `secure://${iv.toString('base64')}:${ciphertext}:${authTag.toString('base64')}`;
118
+ }
119
+
120
+ /**
121
+ * Decrypts an encrypted secret value
122
+ * Handles secure:// prefixed values and extracts IV, ciphertext, and auth tag
123
+ *
124
+ * @function decryptSecret
125
+ * @param {string} encryptedValue - Encrypted value with secure:// prefix
126
+ * @param {string} key - Encryption key (hex or base64, 32 bytes)
127
+ * @returns {string} Decrypted plaintext value
128
+ * @throws {Error} If decryption fails, key is invalid, or format is incorrect
129
+ *
130
+ * @example
131
+ * const decrypted = decryptSecret('secure://<iv>:<ciphertext>:<authTag>', 'a1b2c3...');
132
+ * // Returns: 'my-secret'
133
+ */
134
+ function decryptSecret(encryptedValue, key) {
135
+ if (!encryptedValue || typeof encryptedValue !== 'string') {
136
+ throw new Error('Encrypted value is required and must be a string');
137
+ }
138
+
139
+ if (!encryptedValue.startsWith('secure://')) {
140
+ throw new Error('Encrypted value must start with secure:// prefix');
141
+ }
142
+
143
+ const keyBuffer = normalizeKey(key);
144
+
145
+ // Remove secure:// prefix
146
+ const parts = encryptedValue.substring(9).split(':');
147
+ if (parts.length !== 3) {
148
+ throw new Error('Invalid encrypted value format. Expected: secure://<iv>:<ciphertext>:<authTag>');
149
+ }
150
+
151
+ const [ivBase64, ciphertext, authTagBase64] = parts;
152
+
153
+ try {
154
+ // Decode IV and auth tag
155
+ const iv = Buffer.from(ivBase64, 'base64');
156
+ const authTag = Buffer.from(authTagBase64, 'base64');
157
+
158
+ // Validate lengths
159
+ if (iv.length !== IV_LENGTH) {
160
+ throw new Error(`Invalid IV length: expected ${IV_LENGTH} bytes, got ${iv.length}`);
161
+ }
162
+
163
+ if (authTag.length !== AUTH_TAG_LENGTH) {
164
+ throw new Error(`Invalid auth tag length: expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length}`);
165
+ }
166
+
167
+ // Create decipher
168
+ const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
169
+ decipher.setAuthTag(authTag);
170
+
171
+ // Decrypt
172
+ let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
173
+ plaintext += decipher.final('utf8');
174
+
175
+ return plaintext;
176
+ } catch (error) {
177
+ // Don't expose sensitive details in error messages
178
+ if (error.message.includes('Unsupported state') || error.message.includes('bad decrypt')) {
179
+ throw new Error('Decryption failed: invalid key or corrupted data');
180
+ }
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Checks if a value is encrypted (starts with secure://)
187
+ *
188
+ * @function isEncrypted
189
+ * @param {string} value - Value to check
190
+ * @returns {boolean} True if value is encrypted
191
+ */
192
+ function isEncrypted(value) {
193
+ return typeof value === 'string' && value.startsWith('secure://');
194
+ }
195
+
196
+ module.exports = {
197
+ encryptSecret,
198
+ decryptSecret,
199
+ isEncrypted,
200
+ validateEncryptionKey,
201
+ normalizeKey
202
+ };
203
+