diff --git a/app-config-cli/package.json b/app-config-cli/package.json index b1d8dddf..692e91fe 100644 --- a/app-config-cli/package.json +++ b/app-config-cli/package.json @@ -42,6 +42,7 @@ "@app-config/generate": "^2.6.0", "@app-config/logging": "^2.6.0", "@app-config/node": "^2.6.0", + "@app-config/meta": "^2.6.0", "@app-config/schema": "^2.6.0", "@app-config/utils": "^2.6.0", "ajv": "7", diff --git a/app-config-cli/src/index.ts b/app-config-cli/src/index.ts index 84a03cd0..ae7c1faa 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 } from '@app-config/node'; import { checkTTY, LogLevel, logger } from '@app-config/logging'; import { LoadedConfiguration, @@ -47,6 +47,7 @@ import { } 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 { @@ -318,6 +319,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)) @@ -577,13 +593,19 @@ export const cli = yargs 'Creates properties in meta file, making you the first trusted user', ], ], + options: { + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, + }, }, - async () => { + async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + const myKey = await loadPublicKeyLazy(); const privateKey = await loadPrivateKeyLazy(); // we trust ourselves, essentially - await trustTeamMember(myKey, privateKey); + await trustTeamMember(myKey, privateKey, environmentOptions); logger.info('Initialized team members and a symmetric key'); }, ), @@ -599,10 +621,16 @@ export const cli = yargs 'Sets up a new symmetric key with the latest revision number', ], ], + options: { + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, + }, }, - async () => { - const keys = await loadSymmetricKeys(); - const teamMembers = await loadTeamMembersLazy(); + async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + + const keys = await loadSymmetricKeys(undefined, environmentOptions); + const teamMembers = await loadTeamMembersLazy(environmentOptions); let revision: number; @@ -612,7 +640,12 @@ export const cli = yargs revision = 1; } - await saveNewSymmetricKey(await generateSymmetricKey(revision), teamMembers); + await saveNewSymmetricKey( + await generateSymmetricKey(revision), + teamMembers, + environmentOptions, + ); + logger.info(`Saved a new symmetric key, revision ${revision}`); }, ), @@ -670,12 +703,23 @@ export const cli = yargs name: 'ci', description: 'Creates an encryption key that can be used without a passphrase (useful for CI)', + options: { + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, + }, }, - 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, + ); process.stdout.write(`\n${publicKeyArmored}\n\n${privateKeyArmored}\n\n`); @@ -708,11 +752,17 @@ export const cli = yargs description: 'Filepath of public key', }, }, + options: { + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, + }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + const key = await loadKey(await readFile(opts.keyPath)); const privateKey = await loadPrivateKeyLazy(); - await trustTeamMember(key, privateKey); + await trustTeamMember(key, privateKey, environmentOptions); logger.info(`Trusted ${key.getUserIds().join(', ')}`); }, @@ -736,10 +786,17 @@ export const cli = yargs description: 'User ID email address', }, }, + options: { + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, + }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); const privateKey = await loadPrivateKeyLazy(); - await untrustTeamMember(opts.email, privateKey); + + // TODO: by default, untrust for all envs? + await untrustTeamMember(opts.email, privateKey, environmentOptions); }, ), ) @@ -761,9 +818,13 @@ export const cli = yargs options: { clipboard: clipboardOption, agent: secretAgentOption, + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + shouldUseSecretAgent(opts.agent); // load these right away, so user unlocks asap @@ -797,7 +858,7 @@ export const cli = yargs } } - const encrypted = await encryptValue(secretValue); + const encrypted = await encryptValue(secretValue, undefined, environmentOptions); if (opts.clipboard) { await clipboardy.write(encrypted); @@ -825,9 +886,13 @@ export const cli = yargs options: { clipboard: clipboardOption, agent: secretAgentOption, + environmentOverride: environmentOverrideOption, + environmentVariableName: environmentVariableNameOption, }, }, async (opts) => { + const environmentOptions = await loadEnvironmentOptions(opts); + shouldUseSecretAgent(opts.agent); // load these right away, so user unlocks asap @@ -855,7 +920,9 @@ export const cli = yargs throw new EmptyStdinOrPromptResponse('Failed to read from stdin or prompt'); } - process.stdout.write(JSON.stringify(await decryptValue(encryptedText))); + const decrypted = await decryptValue(encryptedText, undefined, environmentOptions); + + process.stdout.write(JSON.stringify(decrypted)); process.stdout.write('\n'); }, ), diff --git a/app-config-encryption/src/encryption.ts b/app-config-encryption/src/encryption.ts index 916fc56d..921bceb5 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, @@ -278,32 +284,47 @@ 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; + const selected = selectForEnvironment(encryptionKeys, environmentOptions); + + logger.verbose( + `Found ${selected.length} symmetric keys for environment: ${environment ?? 'none'}`, + ); + + return selected; } export async function loadSymmetricKey( revision: number, 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}`); @@ -318,35 +339,48 @@ const symmetricKeys = new Map>(); export async function loadSymmetricKeyLazy( revision: number, 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)!; @@ -359,7 +393,7 @@ export async function encryptValue( if (symmetricKeyOverride) { symmetricKey = symmetricKeyOverride; } else { - symmetricKey = await loadLatestSymmetricKeyLazy(await loadPrivateKeyLazy()); + symmetricKey = await loadLatestSymmetricKeyLazy(await loadPrivateKeyLazy(), environmentOptions); } // all encrypted data is JSON encoded @@ -386,9 +420,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); @@ -410,7 +445,11 @@ export async function decryptValue( ); } - symmetricKey = await loadSymmetricKeyLazy(revisionNumber, await loadPrivateKeyLazy()); + symmetricKey = await loadSymmetricKeyLazy( + revisionNumber, + await loadPrivateKeyLazy(), + environmentOptions, + ); } const armored = `-----BEGIN PGP MESSAGE-----\nVersion: OpenPGP.js VERSION\n\n${base64}\n-----END PGP PUBLIC KEY BLOCK-----`; @@ -431,13 +470,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 })), ), ); @@ -445,16 +491,20 @@ 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, +) { + const teamMembers = await loadTeamMembers(environmentOptions); if (newTeamMember.isPrivate()) { throw new InvalidEncryptionKey( @@ -474,7 +524,7 @@ export async function trustTeamMember(newTeamMember: Key, privateKey: Key) { const newTeamMembers = teamMembers.concat(newTeamMember); const newEncryptionKeys = await reencryptSymmetricKeys( - await loadSymmetricKeys(), + await loadSymmetricKeys(true, environmentOptions), newTeamMembers, privateKey, ); @@ -486,12 +536,21 @@ export async function trustTeamMember(newTeamMember: Key, privateKey: Key) { keyName: key.keyName ?? null, publicKey: key.armor(), })), - encryptionKeys: newEncryptionKeys, + 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 teamMembers = await loadTeamMembers(environmentOptions); const removalCandidates = new Set(); @@ -542,7 +601,7 @@ 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, ); @@ -561,7 +620,12 @@ export async function untrustTeamMember(email: string, privateKey: Key) { keyName: key.keyName ?? null, publicKey: key.armor(), })), - encryptionKeys: newEncryptionKeys, + encryptionKeys: addForEnvironment( + newEncryptionKeys, + meta.encryptionKeys ?? [], + environmentOptions, + true, + ), })); } @@ -600,11 +664,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 } }; @@ -633,6 +697,116 @@ async function saveNewMetaFile(mutate: (props: MetaProperties) => MetaProperties await fs.writeFile(writeFilePath, stringify(writeMeta, writeFileType)); } +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); + }; + + if (Array.isArray(values)) { + return addOrReplace(values); + } + + const environment = currentEnvironment(environmentOptions); + + if (environment === undefined) { + if ('none' in values) { + return { + ...values, + none: addOrReplace(values.none), + }; + } + + if ('default' in values) { + return { + ...values, + default: addOrReplace(values.default), + }; + } + + const environments = Array.from(Object.keys(values).values()).join(', '); + + throw new AppConfigError( + `No current environment selected, found [${environments}] when adding environment-specific encryption options to meta file`, + ); + } + + 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[]); } diff --git a/app-config-encryption/src/index.ts b/app-config-encryption/src/index.ts index 7ad0fc17..1d36af47 100644 --- a/app-config-encryption/src/index.ts +++ b/app-config-encryption/src/index.ts @@ -1,6 +1,7 @@ import type { ParsingExtension } from '@app-config/core'; import { named } from '@app-config/extension-utils'; import { logger } from '@app-config/logging'; +import { environmentOptionsFromContext } from '@app-config/node'; import { DecryptedSymmetricKey, decryptValue } from './encryption'; export * from './encryption'; @@ -12,7 +13,7 @@ export default function encryptedDirective( symmetricKey?: DecryptedSymmetricKey, shouldShowDeprecationNotice?: true, ): ParsingExtension { - return named('encryption', (value) => { + return named('encryption', (value, _, __, ctx) => { if (typeof value === 'string' && value.startsWith('enc:')) { return async (parse) => { if (shouldShowDeprecationNotice) { @@ -21,7 +22,8 @@ export default function encryptedDirective( ); } - const decrypted = await decryptValue(value, symmetricKey); + const environmentOptions = environmentOptionsFromContext(ctx); + const decrypted = await decryptValue(value, symmetricKey, environmentOptions); return parse(decrypted, { fromSecrets: true, parsedFromEncryptedValue: true }); }; diff --git a/app-config-encryption/src/secret-agent.ts b/app-config-encryption/src/secret-agent.ts index 9c9a3da7..ef55fcd8 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; @@ -145,7 +147,7 @@ export async function connectAgent( ); } - const symmetricKey = await loadEncryptedKey(revisionNumber); + const symmetricKey = await loadEncryptedKey(revisionNumber, environmentOptions); const decrypted = await client.Decrypt({ text, symmetricKey }); keepAlive(); @@ -169,11 +171,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 +247,11 @@ export async function getAgentPortOrSocket( return defaultPort; } -async function loadSymmetricKey(revision: number): Promise { - const symmetricKeys = await loadSymmetricKeys(true); +async function loadSymmetricKey( + revision: number, + 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-meta/src/index.ts b/app-config-meta/src/index.ts index 523391be..ab7b1959 100644 --- a/app-config-meta/src/index.ts +++ b/app-config-meta/src/index.ts @@ -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; 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,