diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index 387adb6da29c9c..0928d3c06cd45b 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -337,6 +337,11 @@ echo 'actual-secret' | openssl rsautl -encrypt -pubin -inkey rsa_pub.pem | base6 Replace `actual-secret` with the secret to encrypt. +## privateKeyOld + +Use this field if you need to perform a "key rotation" and support more than one keypair at a time. +Decryption with this key will be attempted after `privateKey`. + ## privateKeyPath Used as an alternative to `privateKey`, if you wish for the key to be read from disk instead. diff --git a/lib/config/decrypt.spec.ts b/lib/config/decrypt.spec.ts index 7466e0a8cbc35a..970c7814571ef3 100644 --- a/lib/config/decrypt.spec.ts +++ b/lib/config/decrypt.spec.ts @@ -31,11 +31,11 @@ describe('config/decrypt', () => { }); it('handles invalid encrypted value', () => { config.encrypted = { a: 1 }; - setGlobalConfig({ privateKey }); + setGlobalConfig({ privateKey, privateKeyOld: 'invalid-key' }); expect(() => decryptConfig(config)).toThrow(Error('config-validation')); }); it('replaces npm token placeholder in npmrc', () => { - setGlobalConfig({ privateKey }); + setGlobalConfig({ privateKey: 'invalid-key', privateKeyOld: privateKey }); // test old key failover config.npmrc = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n'; // eslint-disable-line no-template-curly-in-string config.encrypted = { diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts index e8848f2bb4fb6c..afabca0c6824a8 100644 --- a/lib/config/decrypt.ts +++ b/lib/config/decrypt.ts @@ -6,78 +6,105 @@ import { add } from '../util/sanitize'; import { getGlobalConfig } from './global'; import type { RenovateConfig } from './types'; +export function tryDecryptPublicKeyDefault( + privateKey: string, + encryptedStr: string +): string | null { + let decryptedStr: string = null; + try { + decryptedStr = crypto + .privateDecrypt(privateKey, Buffer.from(encryptedStr, 'base64')) + .toString(); + logger.debug('Decrypted config using default padding'); + } catch (err) { + logger.debug('Failed to decrypt using default padding'); + } + return decryptedStr; +} + +export function tryDecryptPublicKeyPKCS1( + privateKey: string, + encryptedStr: string +): string | null { + let decryptedStr: string = null; + try { + decryptedStr = crypto + .privateDecrypt( + { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PADDING, + }, + Buffer.from(encryptedStr, 'base64') + ) + .toString(); + } catch (err) { + logger.debug('Failed to decrypt using PKCS1 padding'); + } + return decryptedStr; +} + +export function tryDecrypt( + privateKey: string, + encryptedStr: string +): string | null { + let decryptedStr = tryDecryptPublicKeyDefault(privateKey, encryptedStr); + if (!is.string(decryptedStr)) { + decryptedStr = tryDecryptPublicKeyPKCS1(privateKey, encryptedStr); + } + return decryptedStr; +} + export function decryptConfig(config: RenovateConfig): RenovateConfig { logger.trace({ config }, 'decryptConfig()'); const decryptedConfig = { ...config }; - const { privateKey } = getGlobalConfig(); + const { privateKey, privateKeyOld } = getGlobalConfig(); for (const [key, val] of Object.entries(config)) { if (key === 'encrypted' && is.object(val)) { logger.debug({ config: val }, 'Found encrypted config'); if (privateKey) { for (const [eKey, eVal] of Object.entries(val)) { - try { - let decryptedStr: string; - try { - logger.debug('Trying default padding for ' + eKey); - decryptedStr = crypto - .privateDecrypt(privateKey, Buffer.from(eVal, 'base64')) - .toString(); - logger.debug('Decrypted config using default padding'); - } catch (err) { - logger.debug('Trying RSA_PKCS1_PADDING for ' + eKey); - decryptedStr = crypto - .privateDecrypt( - { - key: privateKey, - padding: crypto.constants.RSA_PKCS1_PADDING, - }, - Buffer.from(eVal, 'base64') - ) - .toString(); - // let it throw if the above fails - } - // istanbul ignore if - if (!decryptedStr.length) { - throw new Error('empty string'); - } - logger.debug(`Decrypted ${eKey}`); - if (eKey === 'npmToken') { - const token = decryptedStr.replace(/\n$/, ''); - add(token); - logger.debug( - { decryptedToken: maskToken(token) }, - 'Migrating npmToken to npmrc' - ); - if (is.string(decryptedConfig.npmrc)) { - /* eslint-disable no-template-curly-in-string */ - if (decryptedConfig.npmrc.includes('${NPM_TOKEN}')) { - logger.debug('Replacing ${NPM_TOKEN} with decrypted token'); - decryptedConfig.npmrc = decryptedConfig.npmrc.replace( - /\${NPM_TOKEN}/g, - token - ); - } else { - logger.debug( - 'Appending _authToken= to end of existing npmrc' - ); - decryptedConfig.npmrc = decryptedConfig.npmrc.replace( - /\n?$/, - `\n_authToken=${token}\n` - ); - } - /* eslint-enable no-template-curly-in-string */ + logger.debug('Trying to decrypt ' + eKey); + let decryptedStr = tryDecrypt(privateKey, eVal); + if (privateKeyOld && !is.nonEmptyString(decryptedStr)) { + logger.debug(`Trying to decrypt with old private key`); + decryptedStr = tryDecrypt(privateKeyOld, eVal); + } + if (!is.nonEmptyString(decryptedStr)) { + const error = new Error('config-validation'); + error.validationError = `Failed to decrypt field ${eKey}. Please re-encrypt and try again.`; + throw error; + } + logger.debug(`Decrypted ${eKey}`); + if (eKey === 'npmToken') { + const token = decryptedStr.replace(/\n$/, ''); + add(token); + logger.debug( + { decryptedToken: maskToken(token) }, + 'Migrating npmToken to npmrc' + ); + if (is.string(decryptedConfig.npmrc)) { + /* eslint-disable no-template-curly-in-string */ + if (decryptedConfig.npmrc.includes('${NPM_TOKEN}')) { + logger.debug('Replacing ${NPM_TOKEN} with decrypted token'); + decryptedConfig.npmrc = decryptedConfig.npmrc.replace( + /\${NPM_TOKEN}/g, + token + ); } else { - logger.debug('Adding npmrc to config'); - decryptedConfig.npmrc = `//registry.npmjs.org/:_authToken=${token}\n`; + logger.debug('Appending _authToken= to end of existing npmrc'); + decryptedConfig.npmrc = decryptedConfig.npmrc.replace( + /\n?$/, + `\n_authToken=${token}\n` + ); } + /* eslint-enable no-template-curly-in-string */ } else { - decryptedConfig[eKey] = decryptedStr; - add(decryptedStr); + logger.debug('Adding npmrc to config'); + decryptedConfig.npmrc = `//registry.npmjs.org/:_authToken=${token}\n`; } - } catch (err) { - const error = new Error('config-validation'); - error.validationError = `Failed to decrypt field ${eKey}. Please re-encrypt and try again.`; - throw error; + } else { + decryptedConfig[eKey] = decryptedStr; + add(decryptedStr); } } } else { diff --git a/lib/config/global.ts b/lib/config/global.ts index 5b5e427d901b6e..0d17dd3992d23e 100644 --- a/lib/config/global.ts +++ b/lib/config/global.ts @@ -17,6 +17,7 @@ const repoGlobalOptions = [ 'exposeAllEnv', 'migratePresets', 'privateKey', + 'privateKeyOld', 'localDir', 'cacheDir', ]; diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index c6f9eec3b9d5cf..45dcbc13f993ac 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -452,6 +452,14 @@ const options: RenovateOptions[] = [ replaceLineReturns: true, globalOnly: true, }, + { + name: 'privateKeyOld', + description: 'Secondary/old private key to try.', + stage: 'repository', + type: 'string', + replaceLineReturns: true, + globalOnly: true, + }, { name: 'privateKeyPath', description: 'Path to the Server-side private key.', diff --git a/lib/config/types.ts b/lib/config/types.ts index 0e5fbdff9f0264..ea13c491911235 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -99,7 +99,8 @@ export interface RepoGlobalConfig { dryRun?: boolean; exposeAllEnv?: boolean; migratePresets?: Record; - privateKey?: string | Buffer; + privateKey?: string; + privateKeyOld?: string; localDir?: string; cacheDir?: string; } diff --git a/lib/util/sanitize.ts b/lib/util/sanitize.ts index a169cd92eb8c30..5f1a0f234e13c9 100644 --- a/lib/util/sanitize.ts +++ b/lib/util/sanitize.ts @@ -7,6 +7,7 @@ export const redactedFields = [ 'npmToken', 'npmrc', 'privateKey', + 'privateKeyOld', 'gitPrivateKey', 'forkToken', 'password', diff --git a/lib/workers/global/config/parse/index.spec.ts b/lib/workers/global/config/parse/index.spec.ts index 6b8399e20511b9..066713775ba05c 100644 --- a/lib/workers/global/config/parse/index.spec.ts +++ b/lib/workers/global/config/parse/index.spec.ts @@ -85,7 +85,7 @@ describe('workers/global/config/parse/index', () => { ...defaultEnv, RENOVATE_PRIVATE_KEY_PATH: privateKeyPath, }; - const expected = await readFile(privateKeyPath); + const expected = await readFile(privateKeyPath, 'utf8'); const parsedConfig = await configParser.parseConfigs(env, defaultArgv); expect(parsedConfig).toContainEntries([['privateKey', expected]]); diff --git a/lib/workers/global/config/parse/index.ts b/lib/workers/global/config/parse/index.ts index ea32628c0e1161..7fef39bc09cfe8 100644 --- a/lib/workers/global/config/parse/index.ts +++ b/lib/workers/global/config/parse/index.ts @@ -39,7 +39,7 @@ export async function parseConfigs( } if (!config.privateKey && config.privateKeyPath) { - config.privateKey = await readFile(config.privateKeyPath); + config.privateKey = await readFile(config.privateKeyPath, 'utf8'); delete config.privateKeyPath; }