From ed3ad4066c54f69aa0a5ccaf8188c24ecd62c21f Mon Sep 17 00:00:00 2001 From: Sean Ferguson Date: Wed, 20 Mar 2024 22:55:39 -0400 Subject: [PATCH] v1.8.0. README! --- README.md | 164 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/cli.program.ts | 4 +- src/index.ts | 23 ++++++- src/load.ts | 7 +- 5 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9461baf --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# @signal24/config + +TypeScript package for encrypting & decrypting secrets in and loading config from .env files. Leverages asymmetric keys so that any developer with access can add & update secrets, but only those with the private key (typically admins and your infrastructure itself) can decrypt secrets. + +## How does this work? + +A pair of RSA-2048 keys (one public, one private) are generated as your encryption keys. For each secret that needs to be encrypted: + +- a random AES-256 key is generated +- the secret is encrypted with the AES-256 key +- the key is wrapped with the public RSA key +- the version number (of the encryption scheme), the encrypted/wrapped key, the AES IV, and the AES-encrypted secret are combined into a single payload +- the plaintext secret is replaced with the payload, base64-encoded (and a prefix/suffix to indicate that it's an encrypted value) + +## Installation + +``` +yarn add @signal24/config +``` + +## Setup + +Generate a key pair: + +``` +npx config-cli generate-keys + +# alternatively, use Docker: +docker run --rm -it ghcr.io/signal24/node-config generate-keys +``` + +Create a .env file with typical `key=value` pairs, but suffix any secret key with `_SECRET`. For example: + +``` +CONFIG_ENCRYPTION_KEY=...copied from above... +TWILIO_ACCOUNT_SID=AC123456 +TWILIO_AUTH_TOKEN_SECRET=SecretToken +``` + +Encrypt the secrets: + +``` +npx config-cli encrypt .env + +# alternatively, use Docker +docker run --rm -it -v `pwd`:/src -w /src ghcr.io/signal24/node-config encrypt .env +``` + +Your .env file now contains encrypted values: + +``` +CONFIG_ENCRYPTION_KEY=...copied from above... +TWILIO_ACCOUNT_SID=AC123456 +TWILIO_AUTH_TOKEN_SECRET=$$[AQJLlkLEOjifkSWRHozwOK78xJfym11/utjD7NZwbYXOUTMMXHg+Fa34wt/ytB4LRB2kiD6qXSYTQQLPYRmxN+1/VcvWCATWPUXJEN+pl8MiaO5boOGMYqcTT9JVUQ+dyEZelJkR+fuhzAeoANKyicPFwYa7DiLRwUlLxca/7lnEiROzrh1YNtvWPM0+J3yjjh/zbwbRUWCVFRcP/jmToE5EGifGYhpSjzY004LDWNfF8fKiotZiISMXq8vbDBBpmYugmkHy6Q+DXMIoVsRhg/jY1LSO8ycNaE8eAjgS05tjnXo35Nx9Wr+QSKAU99+M0yK3zfq7nSnIfVQ7IRQXNV4N2Dte02ZX+AkPwNg/mPeWXD+Acnxzu2KDi4R9nmb1Qnk6VJ+BlejbtO+KhGexkDF9a2pvZyN+LDQM3c1OfL/WpqdIZkSsg7fhDWHYnTGUlr1tOxPndptc6im65Kq05/0ynB/e04HMopDz1EmkSXVV] +``` + +New values can be added or existing values updated, and then simply re-run the encrypt command to encrypt the new values. + +## Decrypt & Load Config using API + +``` +import { loadConfig } from '@signal24/config'; + +const config = loadConfig(); + +// or + +const config = loadConfig({ + // key?: string + // the decryption key. defaults to process.env.CONFIG_DECRYPTION_KEY + key: '...long key...' + + // file?: string | string[] + // files to load config from + // default: ['.env', '.env.local'] + // note: in the case of overlapping keys, values loaded later will take priority + // note: .env.*.local should be added to .gitignore + file: '.env.example', + // or + file: ['.env.example-a', '.env.example-b'], + + // env?: string + // an alternative to specifying files. automatically composes file list. + // resulting files list: ['.env', '.env.local', '.env.YOURENV', '.env.YOURENV.local'] + env: 'production', + + // mergeProcessEnv?: boolean + // automatically merge process.env values into the resulting config object + // defaults to true +}); +``` + +## Decrypt & Load Config into process.env using API + +``` +import { loadConfigIntoEnv } from '@signal24/config'; + +loadConfigIntoEnv({ + // same options as above +}); +``` + +## Decrypt & Load Config using Node 'require' + +``` +node -r @signal24/config/load your-app.js +``` + +This invocation will load, decrypt, and parse the `.env` and `.env.local` files (in addition to environment-specific files; see below) into `process.env`. + +The `env` key above will be set to `APP_ENV` environment variable, if set. + +Be sure the decryption key is set as `CONFIG_DECRYPTION_KEY` in your environment. + +## Decrypt & Load Config into other environments + +Using specific files: + +``` +eval $(npx config-cli sh .env .env.local .env.staging) + +# alternatively, use Docker +eval $(docker run --rm -it -v `pwd`:/src -w /src ghcr.io/signal24/node-config sh .env .env.local .env.staging) +``` + +Or, using an automatic file list based on environment name: + +``` +eval $(npx config-cli shenv staging) + +# alternatively, use Docker +eval $(docker run --rm -it -v `pwd`:/src -w /src ghcr.io/signal24/node-config shenv staging) +``` + +Be sure the decryption key is set as `CONFIG_DECRYPTION_KEY` in your environment. + +## Decryption via CLI + +With `CONFIG_DECRYPTION_KEY` in the environment: + +``` +npx config-cli decrypt .env + +# alternatively, use Docker +docker run --rm -it -v `pwd`:/src -w /src ghcr.io/signal24/node-config decrypt .env +``` + +Or, specified as a parameter: + +``` +npx config-cli decrypt -k "LONG_DECRYPTION_KEY" .env + +# alternatively, use Docker +docker run --rm -it -v `pwd`:/src -w /src ghcr.io/signal24/node-config decrypt -k "LONG_DECRYPTION_KEY" .env +``` + +## Other APIs + +- `parseEnvContent(rawDotenvContent: string, decryptionKey?: string): Record` + - Parses string content (in dotenv format), decrypts (if a key is provided), and returns an object of keys and values. +- `encryptConfigData(key: string, data: Record): Record` + - Returns an object of keys and values where the value of any key suffixed with `_SECRET` is encrypted. +- `decryptConfigData(key: string, data: Record): Record` + - Returns an object of keys and values, decrypting any value that is encrypted. diff --git a/package.json b/package.json index c192767..2d09a93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@signal24/config", - "version": "1.7.0", + "version": "1.8.0", "description": "Runtime configuration encryption helpers", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/cli.program.ts b/src/cli.program.ts index a872899..acd314f 100644 --- a/src/cli.program.ts +++ b/src/cli.program.ts @@ -89,8 +89,8 @@ program .description('Generate a public/private key pair for encryption') .action(() => { const { privateKey, publicKey } = generateConfigKeyPair(); - console.log(`CONFIG_ENCRYPTION_KEY=${publicKey}`); - console.log(`CONFIG_DECRYPTION_KEY=${privateKey}`); + console.log(`CONFIG_ENCRYPTION_KEY=${publicKey}\n`); + console.log(`CONFIG_DECRYPTION_KEY=${privateKey}\n`); }); // helpers diff --git a/src/index.ts b/src/index.ts index 3bc8ace..cdcaa33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,17 @@ import { fileExists } from './helpers'; import { readContentFromFile, transformContent } from './reader'; import { ConfigData, DefaultLoadOptions, LoadOptions } from './types'; -export function decryptConfigData(decryptor: Decryptor, data: ConfigData): ConfigData { +export function encryptConfigData(encryptorOrKey: Encryptor | string, data: ConfigData): ConfigData { + const encryptor = encryptorOrKey instanceof Encryptor ? encryptorOrKey : new Encryptor(encryptorOrKey); + const encrypted: ConfigData = {}; + for (const [key, value] of Object.entries(data)) { + encrypted[key] = encryptor.encryptValueIfNotEncrypted(value); + } + return encrypted; +} + +export function decryptConfigData(decryptorOrKey: Decryptor | string, data: ConfigData): ConfigData { + const decryptor = decryptorOrKey instanceof Decryptor ? decryptorOrKey : new Decryptor(decryptorOrKey); const decrypted: ConfigData = {}; for (const [key, value] of Object.entries(data)) { decrypted[key] = decryptor.decryptValueIfEncrypted(value); @@ -23,6 +33,8 @@ export function parseEnvContent(content: string, decryptor export function loadConfig(options?: LoadOptions): T { options = { ...DefaultLoadOptions, ...options }; + delete process.env.CONFIG_DECRYPTION_KEY; + if (!options.file) { const envFiles = options.env ? [`.env.${options.env}`, `.env.${options.env}.local`] : []; options.file = ['.env', '.env.local', ...envFiles]; @@ -41,9 +53,16 @@ export function loadConfig(options?: LoadOptions): T { } } - Object.assign(config, process.env); + if (options.mergeProcessEnv !== false) { + Object.assign(config, process.env); + } return config as T; } +export function loadConfigIntoEnv(options?: LoadOptions): void { + const resolved = loadConfig(options); + Object.assign(process.env, resolved); +} + export { Decryptor, Encryptor }; diff --git a/src/load.ts b/src/load.ts index 7bf0234..17dac51 100644 --- a/src/load.ts +++ b/src/load.ts @@ -1,7 +1,6 @@ -import { loadConfig } from '.'; +import { loadConfigIntoEnv } from '.'; (() => { - const env = process.env.APP_ENV ?? (process.env.NODE_ENV === 'development' ? 'development' : undefined); - const resolved = loadConfig({ env }); - Object.assign(process.env, resolved); + const env = process.env.APP_ENV; + loadConfigIntoEnv({ env }); })();