From cdc083f40feca3f0b68514138adabaeb55c5a927 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Fri, 10 Sep 2021 12:47:33 +0200
Subject: [PATCH] feat(config): privateKeyOld (#11653)

---
 docs/usage/self-hosted-configuration.md       |   5 +
 lib/config/decrypt.spec.ts                    |   4 +-
 lib/config/decrypt.ts                         | 147 +++++++++++-------
 lib/config/global.ts                          |   1 +
 lib/config/options/index.ts                   |   8 +
 lib/config/types.ts                           |   3 +-
 lib/util/sanitize.ts                          |   1 +
 lib/workers/global/config/parse/index.spec.ts |   2 +-
 lib/workers/global/config/parse/index.ts      |   2 +-
 9 files changed, 108 insertions(+), 65 deletions(-)

diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 387adb6da2..0928d3c06c 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 7466e0a8cb..970c781457 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 e8848f2bb4..afabca0c68 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 5b5e427d90..0d17dd3992 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 c6f9eec3b9..45dcbc13f9 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 0e5fbdff9f..ea13c49191 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -99,7 +99,8 @@ export interface RepoGlobalConfig {
   dryRun?: boolean;
   exposeAllEnv?: boolean;
   migratePresets?: Record<string, string>;
-  privateKey?: string | Buffer;
+  privateKey?: string;
+  privateKeyOld?: string;
   localDir?: string;
   cacheDir?: string;
 }
diff --git a/lib/util/sanitize.ts b/lib/util/sanitize.ts
index a169cd92eb..5f1a0f234e 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 6b8399e205..066713775b 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 ea32628c0e..7fef39bc09 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;
   }
 
-- 
GitLab