diff --git a/app-config-cli/package.json b/app-config-cli/package.json index 2dc2dc9a..e51bbd3e 100644 --- a/app-config-cli/package.json +++ b/app-config-cli/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/cli", "description": "CLI for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -35,14 +35,14 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/config": "^2.8.7", - "@app-config/core": "^2.8.7", - "@app-config/encryption": "^2.8.7", - "@app-config/generate": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/config": "^2.9.0-beta.1", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/encryption": "^2.9.0-beta.1", + "@app-config/generate": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "ajv": "7", "clipboardy": "2", "common-tags": "1", @@ -52,7 +52,7 @@ "yargs": "16" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "@types/common-tags": "1", "@types/fs-extra": "9" }, diff --git a/app-config-cli/src/index.ts b/app-config-cli/src/index.ts index 668eb888..7261ef6f 100644 --- a/app-config-cli/src/index.ts +++ b/app-config-cli/src/index.ts @@ -16,7 +16,7 @@ import { FailedToSelectSubObject, EmptyStdinOrPromptResponse, } from '@app-config/core'; -import { promptUser, consumeStdin } from '@app-config/node'; +import { promptUser, consumeStdin, asEnvOptions, currentEnvironment } from '@app-config/node'; import { checkTTY, LogLevel, logger } from '@app-config/logging'; import { LoadedConfiguration, @@ -44,9 +44,11 @@ import { shouldUseSecretAgent, startAgent, disconnectAgents, + getRevisionNumber, } from '@app-config/encryption'; import { loadSchema, JSONSchema } from '@app-config/schema'; import { generateTypeFiles } from '@app-config/generate'; +import { loadMetaConfigLazy } from '@app-config/meta'; import { validateAllConfigVariants } from './validation'; enum OptionGroups { @@ -319,6 +321,21 @@ function fileTypeForFormatOption(option: string): FileType { } } +async function loadEnvironmentOptions(opts: { + environmentOverride?: string; + environmentVariableName?: string; +}) { + const { + value: { environmentAliases, environmentSourceNames }, + } = await loadMetaConfigLazy(); + + return asEnvOptions( + opts.environmentOverride, + environmentAliases, + opts.environmentVariableName ?? environmentSourceNames, + ); +} + export const cli = yargs .scriptName('app-config') .wrap(Math.max(yargs.terminalWidth() - 5, 80)) @@ -579,13 +596,18 @@ export const cli = yargs 'Creates properties in meta file, making you the first trusted user', ], ], + options: { + environmentOverride: environmentOverrideOption, + }, }, - async () => { - const myKey = await loadPublicKeyLazy(); - const privateKey = await loadPrivateKeyLazy(); + async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + + const myKey = await loadPublicKeyLazy(environmentOptions); + const privateKey = await loadPrivateKeyLazy(environmentOptions); // we trust ourselves, essentially - await trustTeamMember(myKey, privateKey); + await trustTeamMember(myKey, privateKey, environmentOptions); logger.info('Initialized team members and a symmetric key'); }, ), @@ -601,20 +623,40 @@ export const cli = yargs 'Sets up a new symmetric key with the latest revision number', ], ], + options: { + environmentOverride: environmentOverrideOption, + }, }, - async () => { - const keys = await loadSymmetricKeys(); - const teamMembers = await loadTeamMembersLazy(); + async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + const environment = currentEnvironment(environmentOptions); + + const keys = await loadSymmetricKeys(undefined, environmentOptions); + const teamMembers = await loadTeamMembersLazy(environmentOptions); - let revision: number; + let revision: string; if (keys.length > 0) { - revision = latestSymmetricKeyRevision(keys) + 1; + const latestRevison = latestSymmetricKeyRevision(keys); + const revNumber = getRevisionNumber(latestRevison); + + if (environment) { + revision = `${environment}-${revNumber + 1}`; + } else { + revision = `${revNumber + 1}`; + } + } else if (environment) { + revision = `${environment}-1`; } else { - revision = 1; + revision = '1'; } - await saveNewSymmetricKey(await generateSymmetricKey(revision), teamMembers); + await saveNewSymmetricKey( + await generateSymmetricKey(revision), + teamMembers, + environmentOptions, + ); + logger.info(`Saved a new symmetric key, revision ${revision}`); }, ), @@ -672,12 +714,22 @@ export const cli = yargs name: 'ci', description: 'Creates an encryption key that can be used without a passphrase (useful for CI)', + options: { + environmentOverride: environmentOverrideOption, + }, }, - async () => { + async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + logger.info('Creating a new trusted CI encryption key'); const { privateKeyArmored, publicKeyArmored } = await initializeKeys(false); - await trustTeamMember(await loadKey(publicKeyArmored), await loadPrivateKeyLazy()); + + await trustTeamMember( + await loadKey(publicKeyArmored), + await loadPrivateKeyLazy(environmentOptions), + environmentOptions, + ); process.stdout.write(`\n${publicKeyArmored}\n\n${privateKeyArmored}\n\n`); @@ -710,11 +762,16 @@ export const cli = yargs description: 'Filepath of public key', }, }, + options: { + environmentOverride: environmentOverrideOption, + }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + const key = await loadKey(await readFile(opts.keyPath)); - const privateKey = await loadPrivateKeyLazy(); - await trustTeamMember(key, privateKey); + const privateKey = await loadPrivateKeyLazy(environmentOptions); + await trustTeamMember(key, privateKey, environmentOptions); logger.info(`Trusted ${key.getUserIds().join(', ')}`); }, @@ -738,10 +795,16 @@ export const cli = yargs description: 'User ID email address', }, }, + options: { + environmentOverride: environmentOverrideOption, + }, }, async (opts) => { - const privateKey = await loadPrivateKeyLazy(); - await untrustTeamMember(opts.email, privateKey); + const environmentOptions = await loadEnvironmentOptions(opts); + const privateKey = await loadPrivateKeyLazy(environmentOptions); + + // TODO: by default, untrust for all envs? + await untrustTeamMember(opts.email, privateKey, environmentOptions); }, ), ) @@ -763,13 +826,16 @@ export const cli = yargs options: { clipboard: clipboardOption, agent: secretAgentOption, + environmentOverride: environmentOverrideOption, }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + shouldUseSecretAgent(opts.agent); // load these right away, so user unlocks asap - if (!shouldUseSecretAgent()) await loadPrivateKeyLazy(); + if (!shouldUseSecretAgent()) await loadPrivateKeyLazy(environmentOptions); let { secretValue }: { secretValue?: Json } = opts; @@ -799,7 +865,7 @@ export const cli = yargs } } - const encrypted = await encryptValue(secretValue); + const encrypted = await encryptValue(secretValue, undefined, environmentOptions); if (opts.clipboard) { await clipboardy.write(encrypted); @@ -827,13 +893,17 @@ export const cli = yargs options: { clipboard: clipboardOption, agent: secretAgentOption, + environmentOverride: environmentOverrideOption, }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + const environment = currentEnvironment(environmentOptions); + shouldUseSecretAgent(opts.agent); // load these right away, so user unlocks asap - if (!shouldUseSecretAgent()) await loadPrivateKeyLazy(); + if (!shouldUseSecretAgent()) await loadPrivateKeyLazy(environmentOptions); let { encryptedText } = opts; @@ -857,7 +927,14 @@ export const cli = yargs throw new EmptyStdinOrPromptResponse('Failed to read from stdin or prompt'); } - process.stdout.write(JSON.stringify(await decryptValue(encryptedText))); + // only use an environment if one was provided - otherwise just find the key to use based on the revision + const decrypted = await decryptValue( + encryptedText, + undefined, + environment ? environmentOptions : undefined, + ); + + process.stdout.write(JSON.stringify(decrypted)); process.stdout.write('\n'); }, ), diff --git a/app-config-config/package.json b/app-config-config/package.json index 584bc1da..da740b4a 100644 --- a/app-config-config/package.json +++ b/app-config-config/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/config", "description": "The config in @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,17 +30,17 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/default-extensions": "^2.8.7", - "@app-config/extensions": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/meta": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7" + "@app-config/core": "^2.9.0-beta.1", + "@app-config/default-extensions": "^2.9.0-beta.1", + "@app-config/extensions": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/meta": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-core/package.json b/app-config-core/package.json index 3b517e1c..e89c4a88 100644 --- a/app-config-core/package.json +++ b/app-config-core/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/core", "description": "Core logic for App Config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,15 +30,15 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/logging": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "@iarna/toml": "3", "js-yaml": "^3.13.1", "json5": "2", "lodash.merge": "^4.6.2" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "@types/js-yaml": "3", "@types/lodash.merge": "4" }, diff --git a/app-config-cypress/package.json b/app-config-cypress/package.json index 19fab046..d67f5f3f 100644 --- a/app-config-cypress/package.json +++ b/app-config-cypress/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/cypress", "description": "Cypress testing plugin for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -31,11 +31,11 @@ }, "dependencies": {}, "peerDependencies": { - "@app-config/main": "^2.8.7", + "@app-config/main": "^2.9.0-beta.1", "cypress": "6" }, "devDependencies": { - "@app-config/main": "^2.8.7", + "@app-config/main": "^2.9.0-beta.1", "cypress": "6" }, "prettier": "@lcdev/prettier", diff --git a/app-config-default-extensions/package.json b/app-config-default-extensions/package.json index 7658c91a..6031e5c1 100644 --- a/app-config-default-extensions/package.json +++ b/app-config-default-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/default-extensions", "description": "Default parsing extensions for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -22,10 +22,10 @@ "test": "jest" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/encryption": "^2.8.7", - "@app-config/extensions": "^2.8.7", - "@app-config/git": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/encryption": "^2.9.0-beta.1", + "@app-config/extensions": "^2.9.0-beta.1", + "@app-config/git": "^2.9.0-beta.1", "@app-config/v1-compat": "^2.1.4" }, "devDependencies": {}, diff --git a/app-config-electron/package.json b/app-config-electron/package.json index 94152c74..a583ebbc 100644 --- a/app-config-electron/package.json +++ b/app-config-electron/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/electron", "description": "Exposes app-config values to Electron render processes", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", diff --git a/app-config-encryption/package.json b/app-config-encryption/package.json index 6919a946..f0c4944d 100644 --- a/app-config-encryption/package.json +++ b/app-config-encryption/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/encryption", "description": "Secret value encryption for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,13 +30,13 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/meta": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/settings": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/meta": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/settings": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "@lcdev/ws-rpc": "0.4", "@types/openpgp": "4", "common-tags": "1", @@ -47,7 +47,7 @@ "ws": "^7.4.6" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "get-port": "5" }, "prettier": "@lcdev/prettier", diff --git a/app-config-encryption/src/encryption.test.ts b/app-config-encryption/src/encryption.test.ts index 5b17f15e..5a55d1d9 100644 --- a/app-config-encryption/src/encryption.test.ts +++ b/app-config-encryption/src/encryption.test.ts @@ -4,6 +4,7 @@ import { SecretsRequireTTYError } from '@app-config/core'; import { loadMetaConfig } from '@app-config/meta'; import { withTempFiles, mockedStdin } from '@app-config/test-utils'; +import { defaultEnvOptions } from '@app-config/node'; import { initializeKeys, initializeKeysManually, @@ -21,6 +22,7 @@ import { loadTeamMembers, trustTeamMember, untrustTeamMember, + getRevisionNumber, } from './encryption'; describe('User Keys', () => { @@ -102,6 +104,77 @@ describe('User Keys', () => { }); }); +const createKeys = async () => { + const { privateKeyArmored, publicKeyArmored } = await initializeKeysManually({ + name: 'Tester', + email: 'test@example.com', + }); + + return { + privateKey: await loadPrivateKey(privateKeyArmored), + publicKey: await loadPublicKey(publicKeyArmored), + privateKeyArmored, + publicKeyArmored, + }; +}; + +describe('User keys from environment', () => { + it('loads user keys from environment', async () => { + const keys = await createKeys(); + + process.env.APP_CONFIG_SECRETS_PUBLIC_KEY = keys.publicKeyArmored; + process.env.APP_CONFIG_SECRETS_KEY = keys.privateKeyArmored; + + const privateKey = await loadPrivateKey(); + const publicKey = await loadPublicKey(); + + expect(privateKey.getFingerprint()).toEqual(keys.privateKey.getFingerprint()); + expect(publicKey.getFingerprint()).toEqual(keys.publicKey.getFingerprint()); + }); + + it('loads environment user keys from environment', async () => { + const keys = await createKeys(); + + process.env.APP_CONFIG_SECRETS_PUBLIC_KEY_PRODUCTION = keys.publicKeyArmored; + process.env.APP_CONFIG_SECRETS_KEY_PRODUCTION = keys.privateKeyArmored; + process.env.APP_CONFIG_ENV = 'prod'; + + const privateKey = await loadPrivateKey(undefined, defaultEnvOptions); + const publicKey = await loadPublicKey(undefined, defaultEnvOptions); + + expect(privateKey.getFingerprint()).toEqual(keys.privateKey.getFingerprint()); + expect(publicKey.getFingerprint()).toEqual(keys.publicKey.getFingerprint()); + }); + + it('loads aliased environment user keys from environment', async () => { + const keys = await createKeys(); + + process.env.APP_CONFIG_SECRETS_PUBLIC_KEY_PROD = keys.publicKeyArmored; + process.env.APP_CONFIG_SECRETS_KEY_PROD = keys.privateKeyArmored; + process.env.APP_CONFIG_ENV = 'prod'; + + const privateKey = await loadPrivateKey(undefined, defaultEnvOptions); + const publicKey = await loadPublicKey(undefined, defaultEnvOptions); + + expect(privateKey.getFingerprint()).toEqual(keys.privateKey.getFingerprint()); + expect(publicKey.getFingerprint()).toEqual(keys.publicKey.getFingerprint()); + }); + + it('falls back to key with no environment', async () => { + const keys = await createKeys(); + + process.env.APP_CONFIG_SECRETS_PUBLIC_KEY = keys.publicKeyArmored; + process.env.APP_CONFIG_SECRETS_KEY = keys.privateKeyArmored; + process.env.APP_CONFIG_ENV = 'prod'; + + const privateKey = await loadPrivateKey(undefined, defaultEnvOptions); + const publicKey = await loadPublicKey(undefined, defaultEnvOptions); + + expect(privateKey.getFingerprint()).toEqual(keys.privateKey.getFingerprint()); + expect(publicKey.getFingerprint()).toEqual(keys.publicKey.getFingerprint()); + }); +}); + const createKey = async () => { const { privateKeyArmored } = await initializeKeysManually({ name: 'Tester', @@ -113,25 +186,25 @@ const createKey = async () => { describe('Symmetric Keys', () => { it('generates a plain symmetric key', async () => { - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); - expect(symmetricKey.revision).toBe(1); + expect(symmetricKey.revision).toBe('1'); expect(symmetricKey.key).toBeInstanceOf(Uint8Array); expect(symmetricKey.key.length).toBeGreaterThan(2048); }); it('encrypts and decrypts a symmetric key', async () => { const privateKey = await createKey(); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedKey = await encryptSymmetricKey(symmetricKey, [privateKey]); - expect(encryptedKey.revision).toBe(1); + expect(encryptedKey.revision).toBe('1'); expect(typeof encryptedKey.key).toBe('string'); expect(encryptedKey.key.length).toBeGreaterThan(0); const decryptedKey = await decryptSymmetricKey(encryptedKey, privateKey); - expect(decryptedKey.revision).toBe(1); + expect(decryptedKey.revision).toBe('1'); expect(decryptedKey.key).toEqual(symmetricKey.key); }); @@ -139,7 +212,7 @@ describe('Symmetric Keys', () => { const privateKey = await createKey(); const someoneElsesKey = await createKey(); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedKey = await encryptSymmetricKey(symmetricKey, [someoneElsesKey]); await expect(decryptSymmetricKey(encryptedKey, privateKey)).rejects.toThrow(); @@ -149,7 +222,7 @@ describe('Symmetric Keys', () => { const privateKey = await createKey(); const someoneElsesKey = await createKey(); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedKey = await encryptSymmetricKey(symmetricKey, [privateKey, someoneElsesKey]); await expect(decryptSymmetricKey(encryptedKey, privateKey)).resolves.toEqual(symmetricKey); @@ -160,7 +233,7 @@ describe('Symmetric Keys', () => { const privateKey = await createKey(); const someoneElsesKey = await createKey(); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedKey = await encryptSymmetricKey(symmetricKey, [privateKey]); const decryptedKey = await decryptSymmetricKey(encryptedKey, privateKey); const encryptedKey2 = await encryptSymmetricKey(decryptedKey, [privateKey, someoneElsesKey]); @@ -172,7 +245,7 @@ describe('Symmetric Keys', () => { it('validates encoded revision number in keys', async () => { const privateKey = await createKey(); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedKey = await encryptSymmetricKey(symmetricKey, [privateKey]); // really go out of our way to mess with the key - this usually results in integrity check failures either way @@ -189,7 +262,7 @@ describe('Value Encryption', () => { const values = ['hello world', 42.42, null, true, { message: 'hello world', nested: {} }]; for (const value of values) { - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encrypted = await encryptValue(value, symmetricKey); const decrypted = await decryptValue(encrypted, symmetricKey); @@ -201,19 +274,179 @@ describe('Value Encryption', () => { it('cannot decrypt a value with the wrong key', async () => { const value = 'hello world'; - const symmetricKey = await generateSymmetricKey(1); - const wrongKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); + const wrongKey = await generateSymmetricKey('1'); const encrypted = await encryptValue(value, symmetricKey); await expect(decryptValue(encrypted, wrongKey)).rejects.toThrow(); }); }); +describe('per environment encryption E2E', () => { + it('sets up, trusts and untrusts users correctly', () => { + const cwd = process.cwd(); + + return withTempFiles({}, async (inDir) => { + // run environmentless + delete process.env.NODE_ENV; + + process.chdir(inDir('.')); + process.env.APP_CONFIG_SECRETS_KEYCHAIN_FOLDER = inDir('keychain'); + + const keys = await initializeKeysManually({ + name: 'Tester', + email: 'test@example.com', + }); + + const dirs = { + keychain: inDir('keychain'), + privateKey: inDir('keychain/private-key.asc'), + publicKey: inDir('keychain/public-key.asc'), + revocationCert: inDir('keychain/revocation.asc'), + }; + + expect(await initializeLocalKeys(keys, dirs)).toEqual({ + publicKeyArmored: keys.publicKeyArmored, + }); + + const publicKey = await loadPublicKey(); + const privateKey = await loadPrivateKey(); + + // this is what init-repo does + await trustTeamMember(publicKey, privateKey); + + // at this point, we should have ourselves trusted, and 1 symmetric key + const { value: meta } = await loadMetaConfig(); + + expect(meta.teamMembers).toHaveProperty('default'); + expect(meta.encryptionKeys).toHaveProperty('default'); + expect((meta.teamMembers! as any).default).toHaveLength(1); + expect((meta.encryptionKeys! as any).default).toHaveLength(1); + + const encryptionKey = await loadLatestSymmetricKey(privateKey); + const encrypted = await encryptValue('a secret value', encryptionKey); + await expect(decryptValue(encrypted, encryptionKey)).resolves.toBe('a secret value'); + + // now lets create a new environment + await trustTeamMember(publicKey, privateKey, { ...defaultEnvOptions, override: 'prod' }); + + // at this point, we should have a default and a prod env with 1 trusted member and 1 key each + const { value: prodEnvMeta } = await loadMetaConfig(); + + expect(prodEnvMeta.teamMembers).toHaveProperty('default'); + expect(prodEnvMeta.encryptionKeys).toHaveProperty('default'); + expect((prodEnvMeta.teamMembers! as any).default).toHaveLength(1); + expect((prodEnvMeta.encryptionKeys! as any).default).toHaveLength(1); + expect(prodEnvMeta.teamMembers).toHaveProperty('production'); + expect(prodEnvMeta.encryptionKeys).toHaveProperty('production'); + expect((prodEnvMeta.teamMembers! as any).production).toHaveLength(1); + expect((prodEnvMeta.encryptionKeys! as any).production).toHaveLength(1); + + const prodEncryptionKey = await loadLatestSymmetricKey(privateKey); + const prodEncrypted = await encryptValue('a secret value', prodEncryptionKey); + await expect(decryptValue(prodEncrypted, prodEncryptionKey)).resolves.toBe('a secret value'); + + const teammateKeys = await initializeKeysManually({ + name: 'A Teammate', + email: 'teammate@example.com', + }); + + const teammatePublicKey = await loadPublicKey(teammateKeys.publicKeyArmored); + const teammatePrivateKey = await loadPrivateKey(teammateKeys.privateKeyArmored); + + await trustTeamMember(teammatePublicKey, privateKey); + + // at this point, we should have 2 team members, but still 1 symmetric key + const { value: metaAfterTrustingTeammate } = await loadMetaConfig(); + + expect(metaAfterTrustingTeammate.teamMembers).toHaveProperty('default'); + expect(metaAfterTrustingTeammate.encryptionKeys).toHaveProperty('default'); + expect((metaAfterTrustingTeammate.teamMembers! as any).default).toHaveLength(2); + expect((metaAfterTrustingTeammate.encryptionKeys! as any).default).toHaveLength(1); + expect(metaAfterTrustingTeammate.teamMembers).toHaveProperty('production'); + expect(metaAfterTrustingTeammate.encryptionKeys).toHaveProperty('production'); + expect((metaAfterTrustingTeammate.teamMembers! as any).production).toHaveLength(1); + expect((metaAfterTrustingTeammate.encryptionKeys! as any).production).toHaveLength(1); + + // ensures that the teammate can now encrypt/decrypt values + const encryptedByTeammate = await encryptValue( + 'a secret value', + await loadLatestSymmetricKey(teammatePrivateKey), + ); + await expect( + decryptValue(encryptedByTeammate, await loadLatestSymmetricKey(teammatePrivateKey)), + ).resolves.toBe('a secret value'); + + // ensures that we can still encrypt/decrypt values + const encryptedByUs = await encryptValue( + 'a secret value', + await loadLatestSymmetricKey(privateKey), + ); + await expect( + decryptValue(encryptedByUs, await loadLatestSymmetricKey(privateKey)), + ).resolves.toBe('a secret value'); + + await untrustTeamMember('teammate@example.com', privateKey); + + // at this point, we should have 1 team members, and a newly generated symmetric key + const { value: metaAfterUntrustingTeammate } = await loadMetaConfig(); + + expect(metaAfterUntrustingTeammate.teamMembers).toHaveProperty('default'); + expect(metaAfterUntrustingTeammate.encryptionKeys).toHaveProperty('default'); + expect((metaAfterUntrustingTeammate.teamMembers! as any).default).toHaveLength(1); + expect((metaAfterUntrustingTeammate.encryptionKeys! as any).default).toHaveLength(2); + expect(metaAfterUntrustingTeammate.teamMembers).toHaveProperty('production'); + expect(metaAfterUntrustingTeammate.encryptionKeys).toHaveProperty('production'); + expect((metaAfterUntrustingTeammate.teamMembers! as any).production).toHaveLength(1); + expect((metaAfterUntrustingTeammate.encryptionKeys! as any).production).toHaveLength(1); + + // ensures that we can still encrypt/decrypt values + const newlyEncryptedByUs = await encryptValue( + 'a secret value', + await loadLatestSymmetricKey(privateKey), + ); + await expect( + decryptValue(newlyEncryptedByUs, await loadLatestSymmetricKey(privateKey)), + ).resolves.toBe('a secret value'); + + // now, the teammate should have no access + await expect(loadLatestSymmetricKey(teammatePrivateKey)).rejects.toThrow(); + + // just for test coverage, create a new symmetric key + const latestSymmetricKey = await loadLatestSymmetricKey(privateKey); + + const newRevisionNumber = getRevisionNumber(latestSymmetricKey.revision) + 1; + + await saveNewSymmetricKey( + await generateSymmetricKey(newRevisionNumber.toString()), + await loadTeamMembers(), + ); + + const { value: metaAfterNewSymmetricKey } = await loadMetaConfig(); + + expect(metaAfterNewSymmetricKey.teamMembers).toHaveProperty('default'); + expect(metaAfterNewSymmetricKey.encryptionKeys).toHaveProperty('default'); + expect((metaAfterNewSymmetricKey.teamMembers! as any).default).toHaveLength(1); + expect((metaAfterNewSymmetricKey.encryptionKeys! as any).default).toHaveLength(3); + expect(metaAfterNewSymmetricKey.teamMembers).toHaveProperty('production'); + expect(metaAfterNewSymmetricKey.encryptionKeys).toHaveProperty('production'); + expect((metaAfterNewSymmetricKey.teamMembers! as any).production).toHaveLength(1); + expect((metaAfterNewSymmetricKey.encryptionKeys! as any).production).toHaveLength(1); + + // get out of the directory, Windows doesn't like unlink while cwd + process.chdir(cwd); + }); + }); +}); + describe('E2E Encrypted Repo', () => { it('sets up, trusts and untrusts users correctly', () => { const cwd = process.cwd(); return withTempFiles({}, async (inDir) => { + // run environmentless + delete process.env.NODE_ENV; + process.chdir(inDir('.')); process.env.APP_CONFIG_SECRETS_KEYCHAIN_FOLDER = inDir('keychain'); @@ -242,8 +475,10 @@ describe('E2E Encrypted Repo', () => { // at this point, we should have ourselves trusted, and 1 symmetric key const { value: meta } = await loadMetaConfig(); - expect(meta.teamMembers).toHaveLength(1); - expect(meta.encryptionKeys).toHaveLength(1); + expect(meta.teamMembers).toHaveProperty('default'); + expect(meta.encryptionKeys).toHaveProperty('default'); + expect((meta.teamMembers! as any).default).toHaveLength(1); + expect((meta.encryptionKeys! as any).default).toHaveLength(1); const encryptionKey = await loadLatestSymmetricKey(privateKey); const encrypted = await encryptValue('a secret value', encryptionKey); @@ -262,8 +497,10 @@ describe('E2E Encrypted Repo', () => { // at this point, we should have 2 team members, but still 1 symmetric key const { value: metaAfterTrustingTeammate } = await loadMetaConfig(); - expect(metaAfterTrustingTeammate.teamMembers).toHaveLength(2); - expect(metaAfterTrustingTeammate.encryptionKeys).toHaveLength(1); + expect(metaAfterTrustingTeammate.teamMembers).toHaveProperty('default'); + expect(metaAfterTrustingTeammate.encryptionKeys).toHaveProperty('default'); + expect((metaAfterTrustingTeammate.teamMembers! as any).default).toHaveLength(2); + expect((metaAfterTrustingTeammate.encryptionKeys! as any).default).toHaveLength(1); // ensures that the teammate can now encrypt/decrypt values const encryptedByTeammate = await encryptValue( @@ -288,8 +525,10 @@ describe('E2E Encrypted Repo', () => { // at this point, we should have 1 team members, and a newly generated symmetric key const { value: metaAfterUntrustingTeammate } = await loadMetaConfig(); - expect(metaAfterUntrustingTeammate.teamMembers).toHaveLength(1); - expect(metaAfterUntrustingTeammate.encryptionKeys).toHaveLength(2); + expect(metaAfterUntrustingTeammate.teamMembers).toHaveProperty('default'); + expect(metaAfterUntrustingTeammate.encryptionKeys).toHaveProperty('default'); + expect((metaAfterUntrustingTeammate.teamMembers! as any).default).toHaveLength(1); + expect((metaAfterUntrustingTeammate.encryptionKeys! as any).default).toHaveLength(2); // ensures that we can still encrypt/decrypt values const newlyEncryptedByUs = await encryptValue( @@ -305,15 +544,20 @@ describe('E2E Encrypted Repo', () => { // just for test coverage, create a new symmetric key const latestSymmetricKey = await loadLatestSymmetricKey(privateKey); + + const newRevisionNumber = getRevisionNumber(latestSymmetricKey.revision) + 1; + await saveNewSymmetricKey( - await generateSymmetricKey(latestSymmetricKey.revision + 1), + await generateSymmetricKey(newRevisionNumber.toString()), await loadTeamMembers(), ); const { value: metaAfterNewSymmetricKey } = await loadMetaConfig(); - expect(metaAfterNewSymmetricKey.teamMembers).toHaveLength(1); - expect(metaAfterNewSymmetricKey.encryptionKeys).toHaveLength(3); + expect(metaAfterNewSymmetricKey.teamMembers).toHaveProperty('default'); + expect(metaAfterNewSymmetricKey.encryptionKeys).toHaveProperty('default'); + expect((metaAfterNewSymmetricKey.teamMembers! as any).default).toHaveLength(1); + expect((metaAfterNewSymmetricKey.encryptionKeys! as any).default).toHaveLength(3); // get out of the directory, Windows doesn't like unlink while cwd process.chdir(cwd); diff --git a/app-config-encryption/src/encryption.ts b/app-config-encryption/src/encryption.ts index a6e69f91..56b36c03 100644 --- a/app-config-encryption/src/encryption.ts +++ b/app-config-encryption/src/encryption.ts @@ -15,7 +15,13 @@ import { } from '@app-config/core'; import { Json } from '@app-config/utils'; import { checkTTY, logger } from '@app-config/logging'; -import { promptUser, promptUserWithRetry } from '@app-config/node'; +import { + aliasesFor, + currentEnvironment, + EnvironmentOptions, + promptUser, + promptUserWithRetry, +} from '@app-config/node'; import { loadMetaConfig, loadMetaConfigLazy, @@ -140,21 +146,21 @@ export async function loadKey(contents: string | Buffer): Promise { return keys[0]; } -export async function loadPrivateKey(override?: string | Buffer): Promise { - if (override === undefined) { - if (process.env.APP_CONFIG_SECRETS_KEY) { - // eslint-disable-next-line no-param-reassign - override = process.env.APP_CONFIG_SECRETS_KEY; - } else if (process.env.APP_CONFIG_SECRETS_KEY_FILE) { - // eslint-disable-next-line no-param-reassign - override = (await fs.readFile(process.env.APP_CONFIG_SECRETS_KEY_FILE)).toString(); - } - } - +export async function loadPrivateKey( + override: string | Buffer | undefined = undefined, + environmentOptions?: EnvironmentOptions, +): Promise { let key: Key; + let overrideKey; if (override) { - key = await loadKey(override); + overrideKey = override; + } else { + overrideKey = await getKeyFromEnv('private', environmentOptions); + } + + if (overrideKey) { + key = await loadKey(overrideKey); } else { if (process.env.CI) { logger.info('Warning! Trying to load encryption keys from home folder in a CI environment'); @@ -184,21 +190,21 @@ export async function loadPrivateKey(override?: string | Buffer): Promise { return key; } -export async function loadPublicKey(override?: string | Buffer): Promise { - if (override === undefined) { - if (process.env.APP_CONFIG_SECRETS_PUBLIC_KEY) { - // eslint-disable-next-line no-param-reassign - override = process.env.APP_CONFIG_SECRETS_PUBLIC_KEY; - } else if (process.env.APP_CONFIG_SECRETS_PUBLIC_KEY_FILE) { - // eslint-disable-next-line no-param-reassign - override = (await fs.readFile(process.env.APP_CONFIG_SECRETS_PUBLIC_KEY_FILE)).toString(); - } - } - +export async function loadPublicKey( + override: string | Buffer | undefined = undefined, + environmentOptions?: EnvironmentOptions, +): Promise { let key: Key; + let overrideKey; if (override) { - key = await loadKey(override); + overrideKey = override; + } else { + overrideKey = await getKeyFromEnv('public', environmentOptions); + } + + if (overrideKey) { + key = await loadKey(overrideKey); } else { if (process.env.CI) { logger.warn('Warning! Trying to load encryption keys from home folder in a CI environment'); @@ -213,17 +219,83 @@ export async function loadPublicKey(override?: string | Buffer): Promise { return key; } +async function getKeyFromEnv(keyType: 'private' | 'public', envOptions?: EnvironmentOptions) { + const env = currentEnvironment(envOptions); + + const envVarPrefix = + keyType === 'private' ? 'APP_CONFIG_SECRETS_KEY' : 'APP_CONFIG_SECRETS_PUBLIC_KEY'; + + if (!envOptions || !env) { + return process.env[envVarPrefix]; + } + + let key = process.env[`${envVarPrefix}_${env.toUpperCase()}`]; + + const tryAliases = (envVarName: (alias: string) => string) => { + const aliases = aliasesFor(env, envOptions.aliases); + + for (const alias of aliases) { + const val = process.env[envVarName(alias.toUpperCase())]; + + if (val) { + return val; + } + } + }; + + // try an alias if we didn't find the key first try + if (!key) { + key = tryAliases((alias) => `${envVarPrefix}_${alias}`); + } + + // see if a file was specified for the environment + if (!key) { + const file = process.env[`${envVarPrefix}_${env.toUpperCase()}_FILE`]; + + if (file) { + key = (await fs.readFile(file)).toString(); + } + } + + // try an env alias if we don't have the key from a file + if (!key) { + const file = tryAliases((alias) => `${envVarPrefix}_${alias}_FILE`); + + if (file) { + key = (await fs.readFile(file)).toString(); + } + } + + // if we didn't find a key with an environment, fallback on one without if it exists + if (!key) { + key = process.env[envVarPrefix]; + } + + // if a key still wasn't found try read from a file specified + if (!key) { + const file = process.env[`${envVarPrefix}_FILE`]; + + if (file) { + key = (await fs.readFile(file)).toString(); + } + } + + return key; +} + let loadedPrivateKey: Promise | undefined; -export async function loadPrivateKeyLazy(): Promise { +export async function loadPrivateKeyLazy(environmentOptions?: EnvironmentOptions): Promise { if (!loadedPrivateKey) { logger.verbose('Loading local private key'); if (checkTTY()) { // help the end user, if they haven't initialized their local keys yet - loadedPrivateKey = initializeLocalKeys().then(() => loadPrivateKey()); + loadedPrivateKey = initializeLocalKeys().then(() => + loadPrivateKey(undefined, environmentOptions), + ); } else { - loadedPrivateKey = loadPrivateKey(); + loadedPrivateKey = loadPrivateKey(undefined, environmentOptions); } } @@ -232,15 +304,17 @@ export async function loadPrivateKeyLazy(): Promise { let loadedPublicKey: Promise | undefined; -export async function loadPublicKeyLazy(): Promise { +export async function loadPublicKeyLazy(environmentOptions?: EnvironmentOptions): Promise { if (!loadedPublicKey) { logger.verbose('Loading local public key'); if (checkTTY()) { // help the end user, if they haven't initialized their local keys yet - loadedPublicKey = initializeLocalKeys().then(() => loadPublicKey()); + loadedPublicKey = initializeLocalKeys().then(() => + loadPublicKey(undefined, environmentOptions), + ); } else { - loadedPublicKey = loadPublicKey(); + loadedPublicKey = loadPublicKey(undefined, environmentOptions); } } @@ -250,11 +324,11 @@ export async function loadPublicKeyLazy(): Promise { export { EncryptedSymmetricKey }; export interface DecryptedSymmetricKey { - revision: number; + revision: string; key: Uint8Array; } -export async function generateSymmetricKey(revision: number): Promise { +export async function generateSymmetricKey(revision: string): Promise { // eslint-disable-next-line @typescript-eslint/await-thenable const rawPassword = await crypto.random.getRandomBytes(2048); const passwordWithRevision = encodeRevisionInPassword(rawPassword, revision); @@ -294,32 +368,51 @@ export async function decryptSymmetricKey( return { revision: encrypted.revision, key: data }; } -export async function saveNewSymmetricKey(symmetricKey: DecryptedSymmetricKey, teamMembers: Key[]) { +export async function saveNewSymmetricKey( + symmetricKey: DecryptedSymmetricKey, + teamMembers: Key[], + environmentOptions?: EnvironmentOptions, +) { const encrypted = await encryptSymmetricKey(symmetricKey, teamMembers); await saveNewMetaFile(({ encryptionKeys = [], ...meta }) => ({ ...meta, - encryptionKeys: [...encryptionKeys, encrypted], + encryptionKeys: addForEnvironment(encrypted, encryptionKeys, environmentOptions), })); } -export async function loadSymmetricKeys(lazy = true): Promise { +export async function loadSymmetricKeys( + lazy = true, + environmentOptions?: EnvironmentOptions, +): Promise { // flag is here mostly for testing const loadMeta = lazy ? loadMetaConfigLazy : loadMetaConfig; + const environment = currentEnvironment(environmentOptions); const { value: { encryptionKeys = [] }, } = await loadMeta(); - return encryptionKeys; + if (environmentOptions) { + const selected = selectForEnvironment(encryptionKeys, environmentOptions); + + logger.verbose( + `Found ${selected.length} symmetric keys for environment: ${environment ?? 'none'}`, + ); + + return selected; + } + + return selectAll(encryptionKeys); } export async function loadSymmetricKey( - revision: number, + revision: string, privateKey: Key, lazyMeta = true, + environmentOptions?: EnvironmentOptions, ): Promise { - const symmetricKeys = await loadSymmetricKeys(lazyMeta); + const symmetricKeys = await loadSymmetricKeys(lazyMeta, environmentOptions); const symmetricKey = symmetricKeys.find((k) => k.revision === revision); if (!symmetricKey) throw new InvalidEncryptionKey(`Could not find symmetric key ${revision}`); @@ -329,40 +422,53 @@ export async function loadSymmetricKey( return decryptSymmetricKey(symmetricKey, privateKey); } -const symmetricKeys = new Map>(); +const symmetricKeys = new Map>(); export async function loadSymmetricKeyLazy( - revision: number, + revision: string, privateKey: Key, + environmentOptions?: EnvironmentOptions, ): Promise { if (!symmetricKeys.has(revision)) { - symmetricKeys.set(revision, loadSymmetricKey(revision, privateKey, true)); + symmetricKeys.set(revision, loadSymmetricKey(revision, privateKey, true, environmentOptions)); } return symmetricKeys.get(revision)!; } -export async function loadLatestSymmetricKey(privateKey: Key): Promise { - const allKeys = await loadSymmetricKeys(false); +export async function loadLatestSymmetricKey( + privateKey: Key, + environmentOptions?: EnvironmentOptions, +): Promise { + const allKeys = await loadSymmetricKeys(false, environmentOptions); - return loadSymmetricKey(latestSymmetricKeyRevision(allKeys), privateKey, false); + return loadSymmetricKey( + latestSymmetricKeyRevision(allKeys), + privateKey, + false, + environmentOptions, + ); } -export async function loadLatestSymmetricKeyLazy(privateKey: Key): Promise { - const allKeys = await loadSymmetricKeys(); +export async function loadLatestSymmetricKeyLazy( + privateKey: Key, + environmentOptions?: EnvironmentOptions, +): Promise { + const allKeys = await loadSymmetricKeys(true, environmentOptions); - return loadSymmetricKeyLazy(latestSymmetricKeyRevision(allKeys), privateKey); + return loadSymmetricKeyLazy(latestSymmetricKeyRevision(allKeys), privateKey, environmentOptions); } export async function encryptValue( value: Json, symmetricKeyOverride?: DecryptedSymmetricKey, + environmentOptions?: EnvironmentOptions, ): Promise { if (!symmetricKeyOverride && shouldUseSecretAgent()) { - const client = await retrieveSecretAgent(); + const client = await retrieveSecretAgent(environmentOptions); if (client) { - const allKeys = await loadSymmetricKeys(); + const allKeys = await loadSymmetricKeys(true, environmentOptions); const latestRevision = latestSymmetricKeyRevision(allKeys); const symmetricKey = allKeys.find((k) => k.revision === latestRevision)!; @@ -375,7 +481,10 @@ export async function encryptValue( if (symmetricKeyOverride) { symmetricKey = symmetricKeyOverride; } else { - symmetricKey = await loadLatestSymmetricKeyLazy(await loadPrivateKeyLazy()); + symmetricKey = await loadLatestSymmetricKeyLazy( + await loadPrivateKeyLazy(environmentOptions), + environmentOptions, + ); } // all encrypted data is JSON encoded @@ -402,9 +511,10 @@ export async function encryptValue( export async function decryptValue( text: string, symmetricKeyOverride?: DecryptedSymmetricKey, + environmentOptions?: EnvironmentOptions, ): Promise { if (!symmetricKeyOverride && shouldUseSecretAgent()) { - const client = await retrieveSecretAgent(); + const client = await retrieveSecretAgent(environmentOptions); if (client) { return client.decryptValue(text); @@ -418,15 +528,11 @@ export async function decryptValue( if (symmetricKeyOverride) { symmetricKey = symmetricKeyOverride; } else { - const revisionNumber = parseFloat(revision); - - if (Number.isNaN(revisionNumber)) { - throw new AppConfigError( - `Encrypted value was invalid, revision was not a number (${revision})`, - ); - } - - symmetricKey = await loadSymmetricKeyLazy(revisionNumber, await loadPrivateKeyLazy()); + symmetricKey = await loadSymmetricKeyLazy( + revision, + await loadPrivateKeyLazy(environmentOptions), + environmentOptions, + ); } const armored = `-----BEGIN PGP MESSAGE-----\nVersion: OpenPGP.js VERSION\n\n${base64}\n-----END PGP PUBLIC KEY BLOCK-----`; @@ -447,13 +553,20 @@ export async function decryptValue( return JSON.parse(data) as Json; } -export async function loadTeamMembers(): Promise { +export async function loadTeamMembers(environmentOptions?: EnvironmentOptions): Promise { + const environment = currentEnvironment(environmentOptions); const { value: { teamMembers = [] }, } = await loadMetaConfig(); + const currentTeamMembers = selectForEnvironment(teamMembers, environmentOptions); + + logger.verbose( + `Found ${currentTeamMembers.length} team members for environment: ${environment ?? 'none'}`, + ); + return Promise.all( - teamMembers.map(({ keyName, publicKey }) => + currentTeamMembers.map(({ keyName, publicKey }) => loadKey(publicKey).then((key) => Object.assign(key, { keyName })), ), ); @@ -461,16 +574,27 @@ export async function loadTeamMembers(): Promise { let loadedTeamMembers: Promise | undefined; -export async function loadTeamMembersLazy(): Promise { +export async function loadTeamMembersLazy(environmentOptions?: EnvironmentOptions): Promise { if (!loadedTeamMembers) { - loadedTeamMembers = loadTeamMembers(); + loadedTeamMembers = loadTeamMembers(environmentOptions); } return loadedTeamMembers; } -export async function trustTeamMember(newTeamMember: Key, privateKey: Key) { - const teamMembers = await loadTeamMembers(); +export async function trustTeamMember( + newTeamMember: Key, + privateKey: Key, + environmentOptions?: EnvironmentOptions, +) { + let teamMembers: Key[] = []; + + try { + teamMembers = await loadTeamMembers(environmentOptions); + } catch { + // if this throws it's just because members for the selected env weren't found + // if the env wasn't found just add it + } if (newTeamMember.isPrivate()) { throw new InvalidEncryptionKey( @@ -489,25 +613,51 @@ export async function trustTeamMember(newTeamMember: Key, privateKey: Key) { const newTeamMembers = teamMembers.concat(newTeamMember); + let currentKeys: EncryptedSymmetricKey[] = []; + + try { + currentKeys = await loadSymmetricKeys(true, environmentOptions); + } catch { + // if this throws it's just because keys for the selected env weren't found + // if the env wasn't found just add it + } + const newEncryptionKeys = await reencryptSymmetricKeys( - await loadSymmetricKeys(), + currentKeys, newTeamMembers, privateKey, + environmentOptions, ); await saveNewMetaFile((meta) => ({ ...meta, - teamMembers: newTeamMembers.map((key) => ({ - userId: key.getUserIds()[0], - keyName: key.keyName ?? null, - publicKey: key.armor(), - })), - encryptionKeys: newEncryptionKeys, + teamMembers: addForEnvironment( + newTeamMembers.map((key) => ({ + userId: key.getUserIds()[0], + keyName: key.keyName ?? null, + publicKey: key.armor(), + })), + meta.teamMembers ?? {}, + environmentOptions, + true, + ), + encryptionKeys: addForEnvironment( + newEncryptionKeys, + meta.encryptionKeys ?? {}, + environmentOptions, + true, + ), })); } -export async function untrustTeamMember(email: string, privateKey: Key) { - const teamMembers = await loadTeamMembers(); +export async function untrustTeamMember( + email: string, + privateKey: Key, + environmentOptions?: EnvironmentOptions, +) { + const environment = currentEnvironment(environmentOptions); + + const teamMembers = await loadTeamMembers(environmentOptions); const removalCandidates = new Set(); @@ -558,13 +708,25 @@ export async function untrustTeamMember(email: string, privateKey: Key) { // of course, nothing stops users from having previously copy-pasted secrets, so they should always be rotated when untrusting old users // reason being, they had previous access to the actual private symmetric key const newEncryptionKeys = await reencryptSymmetricKeys( - await loadSymmetricKeys(), + await loadSymmetricKeys(true, environmentOptions), newTeamMembers, privateKey, + environmentOptions, ); + const latestRevision = latestSymmetricKeyRevision(newEncryptionKeys); + const newRevisionNumber = getRevisionNumber(latestRevision) + 1; + + let newRevision; + + if (environment) { + newRevision = `${environment}-${newRevisionNumber}`; + } else { + newRevision = `${newRevisionNumber}`; + } + const newLatestEncryptionKey = await encryptSymmetricKey( - await generateSymmetricKey(latestSymmetricKeyRevision(newEncryptionKeys) + 1), + await generateSymmetricKey(newRevision), newTeamMembers, ); @@ -572,19 +734,66 @@ export async function untrustTeamMember(email: string, privateKey: Key) { await saveNewMetaFile((meta) => ({ ...meta, - teamMembers: newTeamMembers.map((key) => ({ - userId: key.getUserIds()[0], - keyName: key.keyName ?? null, - publicKey: key.armor(), - })), - encryptionKeys: newEncryptionKeys, + teamMembers: addForEnvironment( + newTeamMembers.map((key) => ({ + userId: key.getUserIds()[0], + keyName: key.keyName ?? null, + publicKey: key.armor(), + })), + meta.teamMembers ?? {}, + environmentOptions, + true, + ), + encryptionKeys: addForEnvironment( + newEncryptionKeys, + meta.encryptionKeys ?? {}, + environmentOptions, + true, + ), })); } +export function getRevisionNumber(revision: string) { + const regex = /^(?:\w*-)?(?\d+)$/; + + const match = regex.exec(revision)?.groups?.revisionNumber; + + if (!match) { + throw new AppConfigError( + `Encryption revision is invalid. Got "${revision}" but expected a number or -"`, + ); + } + + const revisionNumber = parseFloat(match); + + if (Number.isNaN(revisionNumber)) { + throw new AppConfigError( + `Encryption revision is invalid. Got "${revision}" but expected a number or -"`, + ); + } + + return revisionNumber; +} + export function latestSymmetricKeyRevision( keys: (EncryptedSymmetricKey | DecryptedSymmetricKey)[], -): number { - keys.sort((a, b) => a.revision - b.revision); +): string { + keys.sort((a, b) => { + // sort the default keys first + // this is ok because if we have an environment the keys should be filtered by env first + let aRevNum = getRevisionNumber(a.revision); + let bRevNum = getRevisionNumber(b.revision); + + if (!a.revision.includes('-')) { + aRevNum += 1000; + } + + if (!b.revision.includes('-')) { + bRevNum += 1000; + } + + return aRevNum - bRevNum; + }); if (keys.length === 0) throw new InvalidEncryptionKey('No symmetric keys were found'); @@ -595,11 +804,22 @@ async function reencryptSymmetricKeys( previousSymmetricKeys: EncryptedSymmetricKey[], newTeamMembers: Key[], privateKey: Key, + environmentOptions?: EnvironmentOptions, ): Promise { const newEncryptionKeys: EncryptedSymmetricKey[] = []; if (previousSymmetricKeys.length === 0) { - const initialKey = await generateSymmetricKey(1); + let newRevision = '1'; + + if (environmentOptions) { + const env = currentEnvironment(environmentOptions); + + if (env) { + newRevision = `${env}-1`; + } + } + + const initialKey = await generateSymmetricKey(newRevision); const encrypted = await encryptSymmetricKey(initialKey, newTeamMembers); newEncryptionKeys.push(encrypted); @@ -616,11 +836,11 @@ async function reencryptSymmetricKeys( return newEncryptionKeys; } -async function retrieveSecretAgent() { +async function retrieveSecretAgent(environmentOptions?: EnvironmentOptions) { let client; try { - client = await connectAgentLazy(); + client = await connectAgentLazy(undefined, undefined, environmentOptions); } catch (err: unknown) { if (err && typeof err === 'object' && 'error' in err) { const { error } = err as { error: { errno: string } }; @@ -649,6 +869,128 @@ async function saveNewMetaFile(mutate: (props: MetaProperties) => MetaProperties await fs.writeFile(writeFilePath, stringify(writeMeta, writeFileType)); } +function selectAll(values: T[] | Record): T[] { + if (Array.isArray(values)) { + return values; + } + + const allValues: T[] = []; + + for (const key of Object.keys(values)) { + allValues.push(...values[key]); + } + + return allValues; +} + +function selectForEnvironment( + values: T[] | Record, + environmentOptions: EnvironmentOptions | undefined, +): T[] { + if (Array.isArray(values)) { + return values; + } + + const environment = currentEnvironment(environmentOptions); + + if (environment === undefined) { + if ('none' in values) { + return values.none; + } + + if ('default' in values) { + return values.default; + } + + const environments = Array.from(Object.keys(values).values()).join(', '); + + throw new AppConfigError(`No current environment selected, found [${environments}}`); + } + + if (environment in values) { + return values[environment]; + } + + if (environmentOptions?.aliases) { + for (const alias of aliasesFor(environment, environmentOptions.aliases)) { + if (alias in values) { + return values[alias]; + } + } + } + + const environments = Array.from(Object.keys(values).values()).join(', '); + + throw new AppConfigError( + `Current environment was ${environment}, only found [${environments}] when selecting environment-specific encryption options from meta file`, + ); +} + +function addForEnvironment( + add: T | T[], + values: T[] | Record, + environmentOptions: EnvironmentOptions | undefined, + overwrite = false, +): T[] | Record { + const addArray = Array.isArray(add) ? add : [add]; + const addOrReplace = (orig: T[]) => { + if (overwrite) { + return addArray; + } + + return orig.concat(addArray); + }; + + const environment = currentEnvironment(environmentOptions); + + if (Array.isArray(values) && environment) { + throw new AppConfigError( + 'An environment was specified when adding a key but your meta file is not setup to use per environment keys', + ); + } + + if (Array.isArray(values)) { + return addOrReplace(values); + } + + if (environment === undefined) { + if ('none' in values) { + return { + ...values, + none: addOrReplace(values.none), + }; + } + + return { + ...values, + default: addOrReplace(values.default), + }; + } + + if (environment in values) { + return { + ...values, + [environment]: addOrReplace(values[environment]), + }; + } + + if (environmentOptions?.aliases) { + for (const alias of aliasesFor(environment, environmentOptions.aliases)) { + if (alias in values) { + return { + ...values, + [alias]: addOrReplace(values[alias]), + }; + } + } + } + + return { + ...values, + [environment]: addArray, + }; +} + function decodeTypedArray(buf: ArrayBuffer): string { return String.fromCharCode.apply(null, new Uint16Array(buf) as any as number[]); } @@ -665,8 +1007,8 @@ function stringAsTypedArray(str: string): Uint16Array { return bufView; } -function encodeRevisionInPassword(password: Uint8Array, revision: number): Uint8Array { - const revisionBytes = stringAsTypedArray(revision.toString()); +function encodeRevisionInPassword(password: Uint8Array, revision: string): Uint8Array { + const revisionBytes = stringAsTypedArray(revision); const passwordWithRevision = new Uint8Array(password.length + revisionBytes.length + 1); // first byte is the revision length, next N bytes is the revision as a string @@ -677,12 +1019,12 @@ function encodeRevisionInPassword(password: Uint8Array, revision: number): Uint8 return passwordWithRevision; } -function verifyEncodedRevision(password: Uint8Array, expectedRevision: number) { +function verifyEncodedRevision(password: Uint8Array, expectedRevision: string) { const revisionBytesLength = password[0]; const revisionBytes = password.slice(1, 1 + revisionBytesLength); const revision = decodeTypedArray(revisionBytes); - if (parseFloat(revision) !== expectedRevision) { + if (revision !== expectedRevision) { throw new EncryptionEncoding(oneLine` We detected tampering in the encryption key, revision ${expectedRevision}! This error occurs when the revision in the 'encryptionKeys' does not match the one that was embedded into the key. diff --git a/app-config-encryption/src/index.test.ts b/app-config-encryption/src/index.test.ts index 54ec86a4..e174086d 100644 --- a/app-config-encryption/src/index.test.ts +++ b/app-config-encryption/src/index.test.ts @@ -4,7 +4,7 @@ import encryptedDirective from './index'; describe('encryptedDirective', () => { it('loads an encrypted value', async () => { - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const source = new LiteralSource({ foo: await encryptValue('foobar', symmetricKey), @@ -16,7 +16,7 @@ describe('encryptedDirective', () => { }); it('loads an array of encrypted values', async () => { - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const source = new LiteralSource({ foo: [ diff --git a/app-config-encryption/src/index.ts b/app-config-encryption/src/index.ts index 7ad0fc17..03278e43 100644 --- a/app-config-encryption/src/index.ts +++ b/app-config-encryption/src/index.ts @@ -1,4 +1,4 @@ -import type { ParsingExtension } from '@app-config/core'; +import { ParsingExtension } from '@app-config/core'; import { named } from '@app-config/extension-utils'; import { logger } from '@app-config/logging'; import { DecryptedSymmetricKey, decryptValue } from './encryption'; @@ -21,6 +21,8 @@ export default function encryptedDirective( ); } + // we don't need to pass the environment here - we use the key revision + // to determine which symmetric key to use const decrypted = await decryptValue(value, symmetricKey); return parse(decrypted, { fromSecrets: true, parsedFromEncryptedValue: true }); diff --git a/app-config-encryption/src/secret-agent.test.ts b/app-config-encryption/src/secret-agent.test.ts index 50f73979..77e28596 100644 --- a/app-config-encryption/src/secret-agent.test.ts +++ b/app-config-encryption/src/secret-agent.test.ts @@ -28,7 +28,7 @@ describe('Decryption', () => { }); const privateKey = await loadPrivateKey(privateKeyArmored); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedSymmetricKey = await encryptSymmetricKey(symmetricKey, [privateKey]); const port = await getPort(); @@ -84,7 +84,7 @@ describe('Unix Sockets', () => { }); const privateKey = await loadPrivateKey(privateKeyArmored); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const encryptedSymmetricKey = await encryptSymmetricKey(symmetricKey, [privateKey]); const socket = resolve('./temporary-socket-file'); diff --git a/app-config-encryption/src/secret-agent.ts b/app-config-encryption/src/secret-agent.ts index 9c9a3da7..b627ba93 100644 --- a/app-config-encryption/src/secret-agent.ts +++ b/app-config-encryption/src/secret-agent.ts @@ -6,6 +6,7 @@ import { AppConfigError } from '@app-config/core'; import { Json } from '@app-config/utils'; import { logger } from '@app-config/logging'; import { loadSettingsLazy, saveSettings } from '@app-config/settings'; +import type { EnvironmentOptions } from '@app-config/node'; import { Key, @@ -81,6 +82,7 @@ export async function connectAgent( closeTimeoutMs = Infinity, socketOrPortOverride?: number | string, loadEncryptedKey: typeof loadSymmetricKey = loadSymmetricKey, + environmentOptions?: EnvironmentOptions, ) { let client: Client; @@ -137,15 +139,8 @@ export async function connectAgent( keepAlive(); const revision = text.split(':')[1]; - const revisionNumber = parseFloat(revision); - if (Number.isNaN(revisionNumber)) { - throw new AppConfigError( - `Encrypted value was invalid, revision was not a number (${revision})`, - ); - } - - const symmetricKey = await loadEncryptedKey(revisionNumber); + const symmetricKey = await loadEncryptedKey(revision, environmentOptions); const decrypted = await client.Decrypt({ text, symmetricKey }); keepAlive(); @@ -169,11 +164,12 @@ const clients = new Map>(); export async function connectAgentLazy( closeTimeoutMs = 500, socketOrPortOverride?: number | string, + environmentOptions?: EnvironmentOptions, ): ReturnType { const socketOrPort = await getAgentPortOrSocket(socketOrPortOverride); if (!clients.has(socketOrPort)) { - const connection = connectAgent(closeTimeoutMs, socketOrPort); + const connection = connectAgent(closeTimeoutMs, socketOrPort, undefined, environmentOptions); clients.set(socketOrPort, connection); @@ -244,8 +240,11 @@ export async function getAgentPortOrSocket( return defaultPort; } -async function loadSymmetricKey(revision: number): Promise { - const symmetricKeys = await loadSymmetricKeys(true); +async function loadSymmetricKey( + revision: string, + environmentOptions?: EnvironmentOptions, +): Promise { + const symmetricKeys = await loadSymmetricKeys(true, environmentOptions); const symmetricKey = symmetricKeys.find((k) => k.revision === revision); if (!symmetricKey) throw new AppConfigError(`Could not find symmetric key ${revision}`); diff --git a/app-config-esbuild/package.json b/app-config-esbuild/package.json index 6961b018..181a6c88 100644 --- a/app-config-esbuild/package.json +++ b/app-config-esbuild/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/esbuild", "description": "esbuild module resolution support for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,12 +30,12 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/config": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7" + "@app-config/config": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "esbuild": "0.13" }, "prettier": "@lcdev/prettier", diff --git a/app-config-exec/package.json b/app-config-exec/package.json index 9580c048..5bf3361c 100644 --- a/app-config-exec/package.json +++ b/app-config-exec/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/exec", "description": "Generate config by running arbitrary programs", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,14 +30,14 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7" + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1" }, "devDependencies": { - "@app-config/main": "^2.8.7", - "@app-config/test-utils": "^2.8.7" + "@app-config/main": "^2.9.0-beta.1", + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-extension-utils/package.json b/app-config-extension-utils/package.json index a1a42b50..012cd1ff 100644 --- a/app-config-extension-utils/package.json +++ b/app-config-extension-utils/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/extension-utils", "description": "Utilities for writing @app-config parsing extensions", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,7 +30,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", "@serafin/schema-builder": "0.14" }, "devDependencies": {}, diff --git a/app-config-extensions/package.json b/app-config-extensions/package.json index 8a42a799..f3e2ce3d 100644 --- a/app-config-extensions/package.json +++ b/app-config-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/extensions", "description": "Common parsing extensions for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,15 +30,15 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "lodash.isequal": "4" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "@types/lodash.isequal": "4" }, "prettier": "@lcdev/prettier", diff --git a/app-config-generate/package.json b/app-config-generate/package.json index 0b6d810b..cad247bb 100644 --- a/app-config-generate/package.json +++ b/app-config-generate/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/generate", "description": "Code generation for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,9 +30,9 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/logging": "^2.8.7", - "@app-config/meta": "^2.8.7", - "@app-config/schema": "^2.8.7", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/meta": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", "@types/readable-stream": "2", "@types/urijs": "1", "common-tags": "1", @@ -41,7 +41,7 @@ "quicktype-core": "6.0.70" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-git/package.json b/app-config-git/package.json index 54b7e967..2dad4c4b 100644 --- a/app-config-git/package.json +++ b/app-config-git/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/git", "description": "$git directive parsing extension for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,13 +30,13 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/logging": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", "simple-git": "3" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-inject/package.json b/app-config-inject/package.json index 72c42f18..3f557473 100644 --- a/app-config-inject/package.json +++ b/app-config-inject/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/inject", "description": "Runtime injection of app-config into static HTML files", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -34,10 +34,10 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/config": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/schema": "^2.8.7", + "@app-config/config": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", "@types/yargs": "16", "node-html-parser": "1", "yargs": "16" diff --git a/app-config-js/package.json b/app-config-js/package.json index 840b7e3e..7a04bbda 100644 --- a/app-config-js/package.json +++ b/app-config-js/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/js", "description": "Loads a JavaScript module to inject configuration", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,12 +30,12 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/node": "^2.8.7" + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-logging/package.json b/app-config-logging/package.json index ab923070..e04901ec 100644 --- a/app-config-logging/package.json +++ b/app-config-logging/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/logging", "description": "Logging for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,7 +30,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/utils": "^2.8.7" + "@app-config/utils": "^2.9.0-beta.1" }, "devDependencies": {}, "prettier": "@lcdev/prettier", diff --git a/app-config-main/package.json b/app-config-main/package.json index 0668ec00..b6c0a75b 100644 --- a/app-config-main/package.json +++ b/app-config-main/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/main", "description": "Easy to use configuration loader with schema validation", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -35,21 +35,21 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/cli": "^2.8.7", - "@app-config/config": "^2.8.7", - "@app-config/core": "^2.8.7", - "@app-config/default-extensions": "^2.8.7", - "@app-config/encryption": "^2.8.7", - "@app-config/extensions": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/meta": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/cli": "^2.9.0-beta.1", + "@app-config/config": "^2.9.0-beta.1", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/default-extensions": "^2.9.0-beta.1", + "@app-config/encryption": "^2.9.0-beta.1", + "@app-config/extensions": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/meta": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "ajv": "7" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-meta/package.json b/app-config-meta/package.json index 2dfb3a72..f5eefbf7 100644 --- a/app-config-meta/package.json +++ b/app-config-meta/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/meta", "description": "Meta file parsing for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,15 +30,15 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/default-extensions": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/default-extensions": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "fs-extra": "7" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-meta/src/index.ts b/app-config-meta/src/index.ts index be316677..ee665361 100644 --- a/app-config-meta/src/index.ts +++ b/app-config-meta/src/index.ts @@ -27,7 +27,7 @@ export interface TeamMember { } export interface EncryptedSymmetricKey { - revision: number; + revision: string; key: string; } @@ -48,8 +48,10 @@ export interface GenerateFile { } export interface MetaProperties { - teamMembers?: TeamMember[]; - encryptionKeys?: EncryptedSymmetricKey[]; + teamMembers?: TeamMember[] | Record; + encryptionKeys?: + | EncryptedSymmetricKey[] + | Record; generate?: GenerateFile[]; parsingExtensions?: (ParsingExtensionWithOptions | string)[]; environmentAliases?: Record; @@ -99,6 +101,9 @@ export async function loadMetaConfig({ const parsed = await source.read(defaultMetaExtensions()); const value = parsed.toJSON() as MetaProperties; + // normalize all revisions to be strings even if they're numbers + normalizeMetaEncryptionKeyRevisions(value); + const fileSources = parsed.sources.filter((s) => s instanceof FileSource) as FileSource[]; const [{ filePath, fileType }] = fileSources.filter((s) => s.filePath.includes(fileNameBase)); @@ -128,6 +133,26 @@ export async function loadMetaConfig({ } } +function normalizeMetaEncryptionKeyRevisions(meta: MetaProperties): MetaProperties { + const stringifyRevision = (keys: EncryptedSymmetricKey[]) => { + for (const key of keys) { + if (typeof key.revision !== 'string') { + key.revision = (key.revision as number).toString(); + } + } + }; + + if (Array.isArray(meta.encryptionKeys)) { + stringifyRevision(meta.encryptionKeys); + } else if (meta.encryptionKeys) { + for (const env of Object.keys(meta.encryptionKeys)) { + stringifyRevision(meta.encryptionKeys[env]); + } + } + + return meta; +} + let metaConfig: Promise | undefined; export async function loadMetaConfigLazy(options?: MetaLoadingOptions): Promise { diff --git a/app-config-node/package.json b/app-config-node/package.json index 7984be13..e0bb8e64 100644 --- a/app-config-node/package.json +++ b/app-config-node/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/node", "description": "Node.js API for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,14 +30,14 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/logging": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", "@types/prompts": "2", "fs-extra": "9", "prompts": "2" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7", + "@app-config/test-utils": "^2.9.0-beta.1", "@types/fs-extra": "9" }, "prettier": "@lcdev/prettier", diff --git a/app-config-node/src/index.ts b/app-config-node/src/index.ts index 193c5d54..eef579ee 100644 --- a/app-config-node/src/index.ts +++ b/app-config-node/src/index.ts @@ -1,5 +1,6 @@ export { FileSource, FlexibleFileSource, resolveFilepath } from './file-source'; export { + aliasesFor, asEnvOptions, environmentOptionsFromContext, currentEnvironment, diff --git a/app-config-react-native/package.json b/app-config-react-native/package.json index 86b913dd..c9952c70 100644 --- a/app-config-react-native/package.json +++ b/app-config-react-native/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/react-native", "description": "React Native Metro transformer that loads your app-config values into bundles statically", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,12 +30,12 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/node": "^2.8.7", + "@app-config/node": "^2.9.0-beta.1", "semver": "7" }, "peerDependencies": { - "@app-config/config": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/config": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "react-native": ">=0.45.0" }, "devDependencies": { diff --git a/app-config-rollup/package.json b/app-config-rollup/package.json index d341b4fa..dfb9dbba 100644 --- a/app-config-rollup/package.json +++ b/app-config-rollup/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/rollup", "description": "Rollup plugin that resolves @app-config for you", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -34,9 +34,9 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/config": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7" + "@app-config/config": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1" }, "peerDependencies": {}, "devDependencies": { diff --git a/app-config-schema/package.json b/app-config-schema/package.json index 415594a4..3f88181d 100644 --- a/app-config-schema/package.json +++ b/app-config-schema/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/schema", "description": "Schema validation for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,19 +30,19 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "@types/json-schema": "7", "ajv": "7", "ajv-formats": "1", "json-schema-ref-parser": "9" }, "devDependencies": { - "@app-config/encryption": "^2.8.7", - "@app-config/extensions": "^2.8.7", - "@app-config/test-utils": "^2.8.7" + "@app-config/encryption": "^2.9.0-beta.1", + "@app-config/extensions": "^2.9.0-beta.1", + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-schema/src/index.test.ts b/app-config-schema/src/index.test.ts index 1638a8b3..1ad2cb9c 100644 --- a/app-config-schema/src/index.test.ts +++ b/app-config-schema/src/index.test.ts @@ -488,7 +488,7 @@ describe('Validation', () => { }, async (inDir) => { const { validate } = await loadSchema({ directory: inDir('.') }); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const parsed = await ParsedValue.parseLiteral( { @@ -515,7 +515,7 @@ describe('Validation', () => { }, async (inDir) => { const { validate } = await loadSchema({ directory: inDir('.') }); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const parsed = await ParsedValue.parseLiteral( [ @@ -542,7 +542,7 @@ describe('Validation', () => { }, async (inDir) => { const { validate } = await loadSchema({ directory: inDir('.') }); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const parsed = await ParsedValue.parseLiteral( [ @@ -573,7 +573,7 @@ describe('Validation', () => { }, async (inDir) => { const { validate } = await loadSchema({ directory: inDir('.') }); - const symmetricKey = await generateSymmetricKey(1); + const symmetricKey = await generateSymmetricKey('1'); const parsed = await ParsedValue.parseLiteral( { diff --git a/app-config-settings/package.json b/app-config-settings/package.json index 4fff8adc..bb546b40 100644 --- a/app-config-settings/package.json +++ b/app-config-settings/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/settings", "description": "User settings for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,15 +30,15 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "env-paths": "2", "fs-extra": "7" }, "devDependencies": { - "@app-config/test-utils": "^2.8.7" + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-test-utils/package.json b/app-config-test-utils/package.json index 0498c006..7bb90377 100644 --- a/app-config-test-utils/package.json +++ b/app-config-test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/test-utils", "description": "Internal test utilities", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,8 +30,8 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/logging": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "@types/fs-extra": "9", "@types/tmp": "0.2", "fs-extra": "9", diff --git a/app-config-utils/package.json b/app-config-utils/package.json index 11a817d0..813dd4b9 100644 --- a/app-config-utils/package.json +++ b/app-config-utils/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/utils", "description": "Common utilities used in @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", diff --git a/app-config-v1-compat/package.json b/app-config-v1-compat/package.json index ad66614e..f418d83f 100644 --- a/app-config-v1-compat/package.json +++ b/app-config-v1-compat/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/v1-compat", "description": "Version 1 compatibility layer for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,16 +30,16 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/core": "^2.8.7", - "@app-config/extension-utils": "^2.8.7", - "@app-config/logging": "^2.8.7", - "@app-config/node": "^2.8.7", - "@app-config/utils": "^2.8.7", + "@app-config/core": "^2.9.0-beta.1", + "@app-config/extension-utils": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/node": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "fs-extra": "7" }, "devDependencies": { - "@app-config/extensions": "^2.8.7", - "@app-config/test-utils": "^2.8.7" + "@app-config/extensions": "^2.9.0-beta.1", + "@app-config/test-utils": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-vault/package.json b/app-config-vault/package.json index 026e0bf0..f1502153 100644 --- a/app-config-vault/package.json +++ b/app-config-vault/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/vault", "description": "Hashicorp Vault support for App Config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,16 +30,16 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/extension-utils": "^2.8.7", + "@app-config/extension-utils": "^2.9.0-beta.1", "@lcdev/fetch": "^0.1.10", "cross-fetch": "3", "node-vault": "0.9" }, "peerDependencies": { - "@app-config/main": "^2.8.7" + "@app-config/main": "^2.9.0-beta.1" }, "devDependencies": { - "@app-config/main": "^2.8.7" + "@app-config/main": "^2.9.0-beta.1" }, "prettier": "@lcdev/prettier", "jest": { diff --git a/app-config-vite/package.json b/app-config-vite/package.json index 9201cab2..878be5bf 100644 --- a/app-config-vite/package.json +++ b/app-config-vite/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/vite", "description": "Vite plugin for @app-config", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -34,7 +34,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/rollup": "^2.8.7" + "@app-config/rollup": "^2.9.0-beta.1" }, "devDependencies": { "vite": "2" diff --git a/app-config-webpack/package.json b/app-config-webpack/package.json index 106a340f..e6f9fcb9 100644 --- a/app-config-webpack/package.json +++ b/app-config-webpack/package.json @@ -1,7 +1,7 @@ { "name": "@app-config/webpack", "description": "Webpack plugin that loads your app-config values into bundles statically", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -31,19 +31,19 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/config": "^2.8.7", - "@app-config/schema": "^2.8.7", - "@app-config/utils": "^2.8.7", - "@app-config/logging": "^2.8.7", + "@app-config/config": "^2.9.0-beta.1", + "@app-config/logging": "^2.9.0-beta.1", + "@app-config/schema": "^2.9.0-beta.1", + "@app-config/utils": "^2.9.0-beta.1", "loader-utils": "2" }, "peerDependencies": { - "@app-config/main": "^2.8.7", + "@app-config/main": "^2.9.0-beta.1", "html-webpack-plugin": "4 || 5", "webpack": "4 || 5" }, "devDependencies": { - "@app-config/main": "^2.8.7", + "@app-config/main": "^2.9.0-beta.1", "@types/loader-utils": "1", "@webpack-cli/serve": "1", "html-webpack-plugin": "5", diff --git a/docs/guide/intro/encryption.md b/docs/guide/intro/encryption.md index d487a659..55daf29c 100644 --- a/docs/guide/intro/encryption.md +++ b/docs/guide/intro/encryption.md @@ -80,6 +80,9 @@ npx @app-config/cli secret trust ./my-key This will re-sign all encryption keys of the current repository with their public key. This gives them access to any previously encrypted secrets as well. +To specify an encryption environment to trust the user on, set the standard App-Config environment variables (`ENV`, `NODE_ENV`, or `APP_CONFIG_ENV`). +If no environment is specified, the user will be trusted on the `default` environment. + ## Untrusting Users You can untrust users as well. Please rotate secrets if they are a security concern. @@ -92,6 +95,11 @@ npx @app-config/cli secret untrust somebody@example.com This does not require re-encrypting any secrets. Any new encrypted values will use a new key that `somebody@example.com` never had access to. +To specify an encryption environment to untrust the user on, set the standard App-Config environment variables (`ENV`, `NODE_ENV`, or `APP_CONFIG_ENV`). +If no environment is specified, the user will be untrusted on the `default` environment. + +To completely untrust a user from your project you must untrust them from all encryption environments they were trusted on. + ::: warning While the above is true, be wary of how the timeline of events interacts with version control. ::: @@ -112,13 +120,20 @@ Or, in files referenced by: The CLI will output both of these with instructions. +To use different keys for different secret environments suffix the environment variable names with the name of the environment. +For example - to specify the keys that will be used in an environment called `production` use these environment variables: + +- `APP_CONFIG_SECRETS_KEY_PRODUCTION` +- `APP_CONFIG_SECRETS_PUBLIC_KEY_PRODUCTION` + ## Implementation Details -- We store a list of team members public keys in meta files -- We store a list of 'encryptionKeys' (symmetric keys) in meta files +- We store a list of team members public keys per encryption environment in meta files +- We store a list of 'encryptionKeys' (symmetric keys) per encryption environment in meta files - Keys are symmetric, but are themselves stored in encrypted form (encrypted by team members' public keys, which allows any team member to decrypt it) - Once the key is given to somebody, they can always decrypt secrets that were encrypted with it - Keys have 'revision' numbers, so we can use the latest one (revision is embedded into the password itself, to prevent tampering in the YAML) + - The encryption environment is included in the revision to determine which symmetric key set to use to decrypt the secret - By keeping revisions, we can untrust a user without having to re-encrypt every secret made before - You'd likely still want to rotate most passwords, but doing so automatically (dumping out YAML files everywhere) would be extremely difficult to do right - The secrets are already compromised, so manual intervention is needed regardless @@ -151,6 +166,29 @@ It serves no logical roles, it's simply there as metadata for you to identify wh

.app-config.meta.yml

+```yaml +teamMembers: + default: + - userId: Joel Gallant + keyName: Desktop + publicKey: '...' + - userId: Joel Gallant + keyName: Laptop + publicKey: '...' +encryptionKeys: + default: + - revision: 1 + key: '...' +``` + +## Multiple Environments + +It can be useful to have different levels of trust on the same repository. For example, it may make sense for your project to have fewer people able to decrypt the secrets used on production then the secrets for a QA environment. + +To add secret environments to an existing project just move your existing `teamMembers` and `encryptionKeys` values under a `default` property. + +For example: + ```yaml teamMembers: - userId: Joel Gallant @@ -163,3 +201,24 @@ encryptionKeys: - revision: 1 key: '...' ``` + +Becomes + +```yaml +teamMembers: + default: + - userId: Joel Gallant + keyName: Desktop + publicKey: '...' + - userId: Joel Gallant + keyName: Laptop + publicKey: '...' +encryptionKeys: + default: + - revision: 1 + key: '...' +``` + +To create a new encryption environment use the `init-repo` CLI subcommand while setting one of the standard App-Config environment variables (`ENV`, `NODE_ENV`, or `APP_CONFIG_ENV`) with the new encryption environment. You can then use the normal App Config secret CLI commands while specifying the environment to trust and untrust users and encrypt and decrypt secrets for that specific environment. + +It's also possible to reuse encryption keys across environments since App Config secret environments and config environments are not linked. For example, you may have 3 config environments like prod, QA, and staging but only 2 encryption environments prod and QA. The production environment likely has more strict access requirements than staging and QA which may have the same users trusted on them. This allows you to trust a user once on the shared QA/staging encryption environment which will allow them to decrypt secrets used on staging and QA. diff --git a/lcdev-app-config-inject/package.json b/lcdev-app-config-inject/package.json index 722fe8ad..a050ef19 100644 --- a/lcdev-app-config-inject/package.json +++ b/lcdev-app-config-inject/package.json @@ -1,7 +1,7 @@ { "name": "@lcdev/app-config-inject", "description": "Alias for @app-config/inject", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -34,7 +34,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/inject": "^2.8.7" + "@app-config/inject": "^2.9.0-beta.1" }, "devDependencies": {}, "prettier": "@lcdev/prettier", diff --git a/lcdev-app-config-webpack-plugin/package.json b/lcdev-app-config-webpack-plugin/package.json index 39927e51..f128a8e0 100644 --- a/lcdev-app-config-webpack-plugin/package.json +++ b/lcdev-app-config-webpack-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@lcdev/app-config-webpack-plugin", "description": "Alias for @app-config/webpack", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,7 +30,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/webpack": "^2.8.7" + "@app-config/webpack": "^2.9.0-beta.1" }, "devDependencies": {}, "prettier": "@lcdev/prettier", diff --git a/lcdev-app-config/package.json b/lcdev-app-config/package.json index 0c90428d..c583056f 100644 --- a/lcdev-app-config/package.json +++ b/lcdev-app-config/package.json @@ -1,7 +1,7 @@ { "name": "@lcdev/app-config", "description": "Alias for @app-config/main", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -34,8 +34,8 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/cli": "^2.8.7", - "@app-config/main": "^2.8.7" + "@app-config/cli": "^2.9.0-beta.1", + "@app-config/main": "^2.9.0-beta.1" }, "devDependencies": {}, "prettier": "@lcdev/prettier", diff --git a/lcdev-react-native-app-config-transformer/package.json b/lcdev-react-native-app-config-transformer/package.json index 8f6b9007..0d0680eb 100644 --- a/lcdev-react-native-app-config-transformer/package.json +++ b/lcdev-react-native-app-config-transformer/package.json @@ -1,7 +1,7 @@ { "name": "@lcdev/react-native-app-config-transformer", "description": "Alias for @app-config/react-native", - "version": "2.8.7", + "version": "2.9.0-beta.1", "license": "MPL-2.0", "author": { "name": "Launchcode", @@ -30,7 +30,7 @@ "prepublishOnly": "yarn clean && yarn build && yarn build:es" }, "dependencies": { - "@app-config/react-native": "^2.8.7" + "@app-config/react-native": "^2.9.0-beta.1" }, "devDependencies": {}, "prettier": "@lcdev/prettier",