Skip to content

Commit

Permalink
feat(config): privateKeyOld (#11653)
Browse files Browse the repository at this point in the history
  • Loading branch information
rarkins committed Sep 10, 2021
1 parent 19b9311 commit cdc083f
Show file tree
Hide file tree
Showing 9 changed files with 108 additions and 65 deletions.
5 changes: 5 additions & 0 deletions docs/usage/self-hosted-configuration.md
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/config/decrypt.spec.ts
Expand Up @@ -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 = {
Expand Down
147 changes: 87 additions & 60 deletions lib/config/decrypt.ts
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions lib/config/global.ts
Expand Up @@ -17,6 +17,7 @@ const repoGlobalOptions = [
'exposeAllEnv',
'migratePresets',
'privateKey',
'privateKeyOld',
'localDir',
'cacheDir',
];
Expand Down
8 changes: 8 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -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.',
Expand Down
3 changes: 2 additions & 1 deletion lib/config/types.ts
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions lib/util/sanitize.ts
Expand Up @@ -7,6 +7,7 @@ export const redactedFields = [
'npmToken',
'npmrc',
'privateKey',
'privateKeyOld',
'gitPrivateKey',
'forkToken',
'password',
Expand Down
2 changes: 1 addition & 1 deletion lib/workers/global/config/parse/index.spec.ts
Expand Up @@ -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]]);
Expand Down
2 changes: 1 addition & 1 deletion lib/workers/global/config/parse/index.ts
Expand Up @@ -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;
}

Expand Down

0 comments on commit cdc083f

Please sign in to comment.