import crypto from 'crypto'; import is from '@sindresorhus/is'; import * as openpgp from 'openpgp'; import { logger } from '../logger'; import { maskToken } from '../util/mask'; import { regEx } from '../util/regex'; import { addSecretForSanitizing } from '../util/sanitize'; import { GlobalConfig } from './global'; import type { RenovateConfig } from './types'; export async function tryDecryptPgp( privateKey: string, encryptedStr: string ): Promise { if (encryptedStr.length < 500) { // optimization during transition of public key -> pgp return null; } try { const pk = await openpgp.readPrivateKey({ // prettier-ignore armoredKey: privateKey.replace(regEx(/\n[ \t]+/g), '\n'), // little massage to help a common problem }); const startBlock = '-----BEGIN PGP MESSAGE-----\n\n'; const endBlock = '\n-----END PGP MESSAGE-----'; let armoredMessage = encryptedStr.trim(); if (!armoredMessage.startsWith(startBlock)) { armoredMessage = `${startBlock}${armoredMessage}`; } if (!armoredMessage.endsWith(endBlock)) { armoredMessage = `${armoredMessage}${endBlock}`; } const message = await openpgp.readMessage({ armoredMessage, }); const { data } = await openpgp.decrypt({ message, decryptionKeys: pk, }); logger.debug('Decrypted config using openpgp'); return data; } catch (err) { logger.debug({ err }, 'Could not decrypt using openpgp'); return null; } } export function tryDecryptPublicKeyDefault( privateKey: string, encryptedStr: string ): string | null { let decryptedStr: string | null = null; try { decryptedStr = crypto .privateDecrypt(privateKey, Buffer.from(encryptedStr, 'base64')) .toString(); logger.debug('Decrypted config using default padding'); } catch (err) { logger.debug('Could not decrypt using default padding'); } return decryptedStr; } export function tryDecryptPublicKeyPKCS1( privateKey: string, encryptedStr: string ): string | null { let decryptedStr: string | null = null; try { decryptedStr = crypto .privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING, }, Buffer.from(encryptedStr, 'base64') ) .toString(); } catch (err) { logger.debug('Could not decrypt using PKCS1 padding'); } return decryptedStr; } export async function tryDecrypt( privateKey: string, encryptedStr: string, repository: string ): Promise { let decryptedStr: string | null = null; if (privateKey?.startsWith('-----BEGIN PGP PRIVATE KEY BLOCK-----')) { const decryptedObjStr = await tryDecryptPgp(privateKey, encryptedStr); if (decryptedObjStr) { try { const decryptedObj = JSON.parse(decryptedObjStr); const { o: org, r: repo, v: value } = decryptedObj; if (is.nonEmptyString(value)) { if (is.nonEmptyString(org)) { const orgName = org.replace(regEx(/\/$/), ''); // Strip trailing slash if (is.nonEmptyString(repo)) { const scopedRepository = `${orgName}/${repo}`; if (scopedRepository.toLowerCase() === repository.toLowerCase()) { decryptedStr = value; } else { logger.debug( { scopedRepository }, 'Secret is scoped to a different repository' ); const error = new Error('config-validation'); error.validationError = `Encrypted secret is scoped to a different repository: "${scopedRepository}".`; throw error; } } else { const scopedOrg = `${orgName}/`; if ( repository.toLowerCase().startsWith(scopedOrg.toLowerCase()) ) { decryptedStr = value; } else { logger.debug( { scopedOrg }, 'Secret is scoped to a different org' ); const error = new Error('config-validation'); error.validationError = `Encrypted secret is scoped to a different org: "${scopedOrg}".`; throw error; } } } else { const error = new Error('config-validation'); error.validationError = `Encrypted value in config is missing a scope.`; throw error; } } else { const error = new Error('config-validation'); error.validationError = `Encrypted value in config is missing a value.`; throw error; } } catch (err) { logger.warn({ err }, 'Could not parse decrypted string'); } } } else { decryptedStr = tryDecryptPublicKeyDefault(privateKey, encryptedStr); if (!is.string(decryptedStr)) { decryptedStr = tryDecryptPublicKeyPKCS1(privateKey, encryptedStr); } } return decryptedStr; } export async function decryptConfig( config: RenovateConfig, repository: string ): Promise { logger.trace({ config }, 'decryptConfig()'); const decryptedConfig = { ...config }; const { privateKey, privateKeyOld } = GlobalConfig.get(); 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)) { logger.debug('Trying to decrypt ' + eKey); let decryptedStr = await tryDecrypt(privateKey, eVal, repository); if (privateKeyOld && !is.nonEmptyString(decryptedStr)) { logger.debug(`Trying to decrypt with old private key`); decryptedStr = await tryDecrypt(privateKeyOld, eVal, repository); } 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(regEx(/\n$/), ''); addSecretForSanitizing(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( regEx(/\${NPM_TOKEN}/g), token ); } else { logger.debug('Appending _authToken= to end of existing npmrc'); decryptedConfig.npmrc = decryptedConfig.npmrc.replace( regEx(/\n?$/), `\n_authToken=${token}\n` ); } /* eslint-enable no-template-curly-in-string */ } else { logger.debug('Adding npmrc to config'); decryptedConfig.npmrc = `//registry.npmjs.org/:_authToken=${token}\n`; } } else { decryptedConfig[eKey] = decryptedStr; addSecretForSanitizing(decryptedStr); } } } else { logger.error('Found encrypted data but no privateKey'); } delete decryptedConfig.encrypted; } else if (is.array(val)) { decryptedConfig[key] = []; for (const item of val) { if (is.object(item) && !is.array(item)) { (decryptedConfig[key] as RenovateConfig[]).push( await decryptConfig(item as RenovateConfig, repository) ); } else { (decryptedConfig[key] as unknown[]).push(item); } } } else if (is.object(val) && key !== 'content') { decryptedConfig[key] = await decryptConfig( val as RenovateConfig, repository ); } } delete decryptedConfig.encrypted; logger.trace({ config: decryptedConfig }, 'decryptedConfig'); return decryptedConfig; }