#!/usr/bin/env node 'use strict'; const childProcess = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const readline = require('node:readline'); const stream = require('node:stream'); module.exports = { commandDecrypt, commandEncrypt, commandInit, commandKeysRegenerate, commandKeysSet, commandRevisionCheck, createDecipher, decryptFile, encryptFile, getConfig, getFileList, isRevisionActual, readKey, readKeyFile, readRevision, writeKeyFile, writeRevision, }; if (require.main === module) { main(...process.argv.slice(2)).catch((e) => { logError(e instanceof SecryptError ? e.message : e); process.exit(1); }); } async function main(command, ...args) { let actualCommand = command; if (args.includes('--help') || args.includes('-h')) { actualCommand = 'help'; } switch (actualCommand) { case 'decrypt': return commandDecrypt(await getConfig({ args })); case 'encrypt': return commandEncrypt(await getConfig({ args })); case 'init': return commandInit(await getConfig({ args })); case 'keys-regenerate': return commandKeysRegenerate(await getConfig({ args })); case 'keys-set': return commandKeysSet(await getConfig({ args })); case 'revision-check': return commandRevisionCheck(await getConfig({ args })); default: return commandHelp(); } } async function commandDecrypt(config) { validateConfig(config); await runHook('preDecrypt', config); const fileList = await config.getFileListFn(config); for (const file of fileList) { await config.decryptFn(file); logInfo('decrypt', file.encrypted.rel, '→', file.decrypted.rel); } await writeRevision(config, await readRevision(config), { primary: false }); const plural = fileList.length === 1 ? '' : 's'; logInfo(`${fileList.length} file${plural} decrypted successfully`); await runHook('postDecrypt', config); } async function commandEncrypt(config) { validateConfig(config); await runHook('preEncrypt', config); const fileList = await config.getFileListFn(config); let encryptedCount = 0; for (const file of fileList) { if (!config.force && !(await wasChanged(file))) { await runHook('skipEncrypt', config, file.decrypted); logInfo('skip unchanged', file.decrypted.rel); continue; } await config.encryptFn(file); logInfo('encrypt', file.decrypted.rel, '→', file.encrypted.rel); encryptedCount += 1; } if (encryptedCount > 0) { await writeRevision(config, await readRevision(config) + 1); } if (encryptedCount === 0) { logInfo('no files were encrypted'); } else { const plural = encryptedCount === 1 ? '' : 's'; logInfo(`${encryptedCount} file${plural} encrypted successfully`); } await runHook('postEncrypt', config); } async function commandInit(config) { const environment = config.environment === 'all' ? 'dev' : config.environment; const { keyFile, keys, messages, prefix } = config; await runHook('preInit', config); let configPath = ''; if (read(path.join(prefix, 'package.json'))?.secrypt) { configPath = path.join(prefix, 'package.json'); } for (const ext of ['mjs', 'cjs', 'js', 'json']) { if (!configPath) { const p = path.join(prefix, `secrypt.config.${ext}`); configPath = fs.existsSync(p) ? p : ''; } } if (configPath) { logInfo(`Secrypt already initialized with config at ${configPath}`); if (Object.values(keys).filter(Boolean).length === 0) { logInfo(messages.pasteKeysOnInit); await commandKeysSet(config); } const decryptConfig = { ...config, allowEmptyFileList: true }; const files = await config.getFileListFn(decryptConfig); if (files.length > 0 && files.every(({ encrypted }) => encrypted.exists)) { await commandDecrypt(decryptConfig); } await runHook('alreadyInit', config); await runHook('postInit', config); return; } if (!fs.existsSync(keyFile)) { await writeKeyFile(keyFile, { [environment]: keys[environment] || crypto.randomBytes(32).toString('base64url').replace(/\W/g, ''), }); } configPath = path.join(prefix, 'secrypt.config.json'); await writeJson(configPath, { files: { [environment]: [] } }); logInfo( 'Two new files were created:', `\nConfig: ${configPath}`, `\nKey file: ${keyFile}`, '\n\nPlease, update the config file with the file list to encrypt/decrypt.', 'Make sure the key file and your unencrypted files are added to gitignore.', ); await runHook('successfulInit', config); await runHook('postInit', config); } async function commandHelp() { logInfo([ 'Usage: secrypt COMMAND [options]', '', 'Commands:', ' encrypt [--force] [...ONLY_THESE_FILES]', ' decrypt [...ONLY_THESE_FILES]', ' init', ' keys-regenerate', ' keys-set', ' revision-check [--decrypt if old] [--code process exit code]', '', 'Common options:', ' -c, --config PATH Config file path (default: secrypt.config.json)', ' -e, --environment ENV Environment name (default: dev)', ' -p, --prefix PATH Change current working directory', '', 'Environment variables:', ' SECRYPT_KEY Set the key for the current env', ' SECRYPT_KEY_{ENV} Set the key for the specified env (uppercased)', ' SECRYPT_KEYS Set the keys, the same format as in secrypt.keys', ' SECRYPT_PREFIX Set working directory path', ' NODE_ENV Set the current env name', ].join('\n')); } async function commandKeysRegenerate(config) { await runHook('preKeysRegenerate', config); const { keyFile } = config; const keys = {}; for (const env of Object.keys(config.files)) { keys[env] = crypto.randomBytes(32).toString('base64url').replace(/\W/g, ''); } await writeKeyFile(keyFile, keys); await runHook('postKeysRegenerate', config); } async function commandKeysSet(config) { await runHook('preKeysSet', config); const { keyFile } = config; logInfo('Paste encryption keys here and press ENTER:'); const keyLines = (await readText()).filter((l) => l && l.match(/\w+: \w+/)); if (keyLines.length < 1) { throw new SecryptError(`No keys provided. Set keys in ${keyFile} manually`); } await fs.promises.writeFile(keyFile, `${keyLines.join('\n')}\n`, 'utf8'); // eslint-disable-next-line no-param-reassign config.keys = await readKeyFile(keyFile); logInfo(`Encryption keys successfully saved to ${keyFile}`); await runHook('postKeysSet', config); } async function commandRevisionCheck(config) { validateConfig(config); await runHook('preRevisionCheck', config); if (await isRevisionActual(config)) { await runHook('postRevisionCheck', config); return; } await runHook('revisionOld', config); if (config.code) { process.exitCode = config.code; } if (config.decrypt) { await commandDecrypt(config); } else { await runHook('revisionUpdateMessage', config); } await runHook('postRevisionCheck', config); } async function createDecipher({ filePath, key }) { const header = await readFirstBytes(filePath, 64); const salt = header.subarray(16, 48); const iv = header.subarray(48, 64); const cryptoKey = crypto.pbkdf2Sync(key, salt, 100_000, 32, 'sha512'); const decipher = crypto.createDecipheriv('aes-256-cbc', cryptoKey, iv); const encryptedStream = fs.createReadStream(filePath, { start: header.length, }); return { decipher, encryptedStream, header, }; } async function decryptFile({ decrypted, encrypted, key }) { const { decipher, encryptedStream } = await createDecipher({ filePath: encrypted.full, key, }); const output = fs.createWriteStream(decrypted.full); try { await stream.promises.pipeline(encryptedStream, decipher, output); } catch (e) { if (e?.code === 'ERR_OSSL_BAD_DECRYPT') { throw new Error( `Wrong key. Can't decrypt ${encrypted.rel}. ${e.message}`, ); } throw e; } } async function encryptFile({ decrypted, encrypted, key }) { const salt = crypto.randomBytes(32); const iv = crypto.randomBytes(16); const cryptoKey = crypto.pbkdf2Sync(key, salt, 100_000, 32, 'sha512'); const cipher = crypto.createCipheriv('aes-256-cbc', cryptoKey, iv); const input = fs.createReadStream(decrypted.full); const output = fs.createWriteStream(encrypted.full); // 0-1: format version, 1-16: reserved, 16-48: salt, 48-64: iv const header = Buffer.concat([Buffer.alloc(1), Buffer.alloc(15), salt, iv]); await new Promise((resolve, reject) => { output.write(header, (e) => (e ? reject(e) : resolve())); }); await stream.promises.pipeline(input, cipher, output); } async function isRevisionActual(config) { const revision = await readRevision(config); const localRevision = await readRevision(config, { local: true }); return revision <= localRevision; } /** * @param {string} fileName * @param {string} [cwd] * @return {string} */ function findUp(fileName, cwd) { let currentPath = cwd; // eslint-disable-next-line no-constant-condition while (true) { const { root, dir } = path.parse(currentPath); if (fs.existsSync(path.join(currentPath, fileName))) { return currentPath; } if (currentPath === root) { return ''; } currentPath = dir; } } async function getConfig({ args = [], env = process.env, cwd = process.cwd(), } = {}) { const aliases = { c: 'config', e: 'environment', p: 'prefix' }; const cli = { params: [] }; let skipNext = false; args.forEach((arg, i) => { if (skipNext) { skipNext = false; return; } if (arg.startsWith('-')) { const key = arg.replace(/^-+/, ''); cli[aliases[key] || key] = args[i + 1] || 'true'; skipNext = true; return; } cli.params.push(arg); }); const environment = cli.environment || env.NODE_ENV || 'all'; const prefix = (cli.prefix ? path.resolve(cwd, cli.prefix) : null) || (env.SECRYPT_PREFIX ? path.join(cwd, env.SECRYPT_PREFIX) : null) || findUp('secrypt.keys', cwd) || findUp('secrypt.config.mjs', cwd) || findUp('secrypt.config.cjs', cwd) || findUp('secrypt.config.js', cwd) || findUp('secrypt.config.json', cwd) || findUp('package.json', cwd) || cwd; const fileConfig = (cli.config && read(path.resolve(prefix, cli.config))) || read(path.join(prefix, 'secrypt.config.mjs')) || read(path.join(prefix, 'secrypt.config.cjs')) || read(path.join(prefix, 'secrypt.config.js')) || read(path.join(prefix, 'secrypt.config.json')) || read(path.join(prefix, 'package.json'), {}).secrypt || {}; const keyFile = path.join(prefix, fileConfig.keyFile || 'secrypt.keys'); const revisionFile = fileConfig.revisionFile && path.join(prefix, fileConfig.revisionFile); const keys = (await readKeyFile(keyFile)) || fileConfig.keys || {}; if (env.SECRYPT_KEY) { keys[environment === 'all' ? 'dev' : environment] = env.SECRYPT_KEY; } if (env.SECRYPT_KEYS?.length > 3) { Object.assign(keys, readKey(env.SECRYPT_KEYS)); } Object.keys(fileConfig.files || {}).forEach((key) => { const envSecret = env[`SECRYPT_KEY_${key.toUpperCase()}`]; keys[key] = envSecret || keys[key]; }); return { decryptFn: decryptFile, encryptFn: encryptFile, getFileListFn: getFileList, resolveEncryptedPathFn: (filePath) => `${filePath}.enc`, hooks: {}, files: {}, ...fileConfig, ...cli, keyFile, keys, messages: { pasteKeysOnInit: 'You can set encryption keys now. Press CTRL+C to skip', revisionOld: 'Your local secrets are outdated', revisionUpdateMessage: 'Run `secrypt decrypt` to update local secrets', ...fileConfig?.messages, }, environment, prefix, revisionFile, }; } async function getFileList(config) { const { allowEmptyFileList = false, environment, keys, prefix } = config; let list = []; for (const [env, envFiles] of Object.entries(config.files)) { if (!keys[env] || (environment !== 'all' && env !== environment)) { continue; } for (const envFile of envFiles) { const decrypted = path.resolve(prefix, envFile); const encrypted = config.resolveEncryptedPathFn(decrypted); list.push({ encrypted: { exists: fs.existsSync(encrypted), full: encrypted, rel: path.relative(prefix, encrypted), }, decrypted: { exists: fs.existsSync(decrypted), full: decrypted, rel: path.relative(prefix, decrypted), }, key: keys[env], }); } } list = list.filter(({ key, decrypted, encrypted }) => { if (!key) { return false; } if (!decrypted.exists && !encrypted.exists) { return false; } if (config.params.length > 0) { return config.params.some((p) => decrypted.full.endsWith(p)); } return true; }); if (list.length === 0 && !allowEmptyFileList) { let reason; if (Object.keys(keys).length === 0) { reason = 'secret keys are not provided'; } else if (Object.keys(config.files).length === 0) { reason = 'files option in config isn\'t set'; } throw new SecryptError( `No files to encrypt found${reason ? `: ${reason}` : ''}.`, ); } return list; } function logInfo(...args) { // eslint-disable-next-line no-console console.info(...args); } function logError(...args) { // eslint-disable-next-line no-console console.error(...args); } function read(filePath, returnOnFail = undefined) { try { // eslint-disable-next-line import/no-dynamic-require,global-require return require(filePath); } catch (e) { return returnOnFail; } } async function readFirstBytes(filePath, size) { const chunks = []; const readStream = fs.createReadStream(filePath, { start: 0, end: size - 1 }); for await (const chunk of readStream) { chunks.push(chunk); } return Buffer.concat(chunks); } function readKey(content) { const result = {}; const lines = content.split('\n').filter((line) => line.match(/^\w/i)); for (const line of lines) { const [key, ...parts] = line.split(':').map((s) => s.trim()); result[key] = parts.join(':'); } return result; } async function readKeyFile(keyPath) { try { return readKey(await fs.promises.readFile(keyPath, 'utf8')); } catch (e) { return undefined; } } async function readText() { const lines = []; const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.on('line', (line) => { lines.push(line.trim()); if (line.trim() === '') { rl.close(); resolve(lines); } }); }); } async function readRevision(config, { local = false } = {}) { try { const name = local ? config.revisionFile + '.local' : config.revisionFile; const text = await fs.promises.readFile(name, 'utf8'); return Number.parseInt(text, 10) || 0; } catch { return 0; } } async function runHook(hookName, config, ...args) { if (typeof config.hooks[hookName] === 'function') { // eslint-disable-next-line no-await-in-loop await config.hooks[hookName](config, ...args); } if (typeof config.hooks[hookName] === 'string') { let command = config.hooks[hookName]; for (const [key, value] of Object.entries(config)) { command = command.replace(`{${key}}`, value); } const child = childProcess.spawn(command, { shell: true, stdio: 'inherit', }); await new Promise((resolve, reject) => { child .on('error', reject) .on('close', (code) => { if (code) { reject(new Error(`Command "${command}" quit, code: ${code}`)); } else { resolve(); } }); }); } if (typeof config.messages[hookName] === 'string') { logInfo(config.messages[hookName]); } } function validateConfig(config) { if (!config.environment) { throw new SecryptError('Environment is not configured'); } if (Object.keys(config.files) < 1) { throw new SecryptError('Files are not configured'); } if (Object.keys(config.keys) < 1) { throw new SecryptError('Key is required'); } } async function wasChanged({ decrypted, encrypted, key }) { try { const { decipher, encryptedStream } = await createDecipher({ filePath: encrypted.full, key, }); const decryptedStream = fs.createReadStream(decrypted.full); return !await streamsEqual(decryptedStream, encryptedStream.pipe(decipher)); } catch (e) { return true; } async function streamsEqual(streamA, streamB) { return await streamHash(streamA) === await streamHash(streamB); } async function streamHash(readableStream) { const hash = crypto.createHash('sha256'); await stream.promises.pipeline(readableStream, hash); return hash.digest('hex'); } } async function writeKeyFile(keyPath, keys) { const lines = Object.entries(keys).map(([k, v]) => `${k}: ${v}`); await fs.promises.writeFile(keyPath, lines.join('\n') + '\n', 'utf8'); } async function writeJson(filePath, data) { await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8'); } async function writeRevision( config, value, { local = true, primary = true } = {}, ) { if (!config.revisionFile) { return; } try { const revision = value.toString(); if (primary) { await fs.promises.writeFile(config.revisionFile, revision); } if (local) { await fs.promises.writeFile(config.revisionFile + '.local', revision); } await runHook('writeRevision', config); } catch { // Skip for now } } class SecryptError extends Error {}