diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d02fedf3f3e707250bcf58cf9e9f56f4e6aee547..b04a619e166e801ff8f629c867ed27ea8f79a855 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,6 +32,7 @@ env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} NODE_VERSION: 18 DRY_RUN: true + TEST_LEGACY_DECRYPTION: true SPARSE_CHECKOUT: |- .github/actions/ data/ diff --git a/jest.config.ts b/jest.config.ts index cf59e0deb91a898f5d70b9d011921a317ec8df2a..9aa56f29293e1be49cdf4120f8c4a2f82499336f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import os from 'node:os'; +import { env } from 'node:process'; import v8 from 'node:v8'; import { minimatch } from 'minimatch'; import type { JestConfigWithTsJest } from 'ts-jest'; @@ -205,11 +206,7 @@ const config: JestConfig = { '!lib/**/{__fixtures__,__mocks__,__testutil__,test}/**/*.{js,ts}', '!lib/**/types.ts', ], - coveragePathIgnorePatterns: [ - '/node_modules/', - '<rootDir>/test/', - '<rootDir>/tools/', - ], + coveragePathIgnorePatterns: getCoverageIgnorePatterns(), cacheDirectory: '.cache/jest', collectCoverage: true, coverageReporters: ci @@ -450,3 +447,12 @@ process.stderr.write(`Host stats: Memory: ${(mem / 1024 / 1024 / 1024).toFixed(2)} GB HeapLimit: ${(stats.heap_size_limit / 1024 / 1024 / 1024).toFixed(2)} GB `); +function getCoverageIgnorePatterns(): string[] | undefined { + const patterns = ['/node_modules/', '<rootDir>/test/', '<rootDir>/tools/']; + + if (env.TEST_LEGACY_DECRYPTION !== 'true') { + patterns.push('<rootDir>/lib/config/decrypt/legacy.ts'); + } + + return patterns; +} diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts index a9706d97805cfb9e6491d0cddef9a224fcf49c83..127301308e691a9038bf7bd4cf2122ba47b58849 100644 --- a/lib/config/decrypt.ts +++ b/lib/config/decrypt.ts @@ -1,89 +1,18 @@ -import crypto from 'node: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 { ensureTrailingSlash } from '../util/url'; +import { + tryDecryptPublicKeyDefault, + tryDecryptPublicKeyPKCS1, +} from './decrypt/legacy'; +import { tryDecryptOpenPgp } from './decrypt/openpgp'; import { GlobalConfig } from './global'; import { DecryptedObject } from './schema'; import type { RenovateConfig } from './types'; -export async function tryDecryptPgp( - privateKey: string, - encryptedStr: string, -): Promise<string | null> { - 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, @@ -92,7 +21,7 @@ export async function tryDecrypt( ): Promise<string | null> { let decryptedStr: string | null = null; if (privateKey?.startsWith('-----BEGIN PGP PRIVATE KEY BLOCK-----')) { - const decryptedObjStr = await tryDecryptPgp(privateKey, encryptedStr); + const decryptedObjStr = await tryDecryptOpenPgp(privateKey, encryptedStr); if (decryptedObjStr) { decryptedStr = validateDecryptedValue(decryptedObjStr, repository); } diff --git a/lib/config/decrypt/legacy.ts b/lib/config/decrypt/legacy.ts new file mode 100644 index 0000000000000000000000000000000000000000..caa1e8d4058824af3c7da62f45b1c1c92aeb02ac --- /dev/null +++ b/lib/config/decrypt/legacy.ts @@ -0,0 +1,40 @@ +/** istanbul ignore file */ +import crypto from 'node:crypto'; +import { logger } from '../../logger'; + +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 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; +} diff --git a/lib/config/decrypt/openpgp.ts b/lib/config/decrypt/openpgp.ts new file mode 100644 index 0000000000000000000000000000000000000000..570b0d696ea7bb6ce0f8e7d178ae24a0dbf72047 --- /dev/null +++ b/lib/config/decrypt/openpgp.ts @@ -0,0 +1,40 @@ +import * as openpgp from 'openpgp'; +import { logger } from '../../logger'; +import { regEx } from '../../util/regex'; + +export async function tryDecryptOpenPgp( + privateKey: string, + encryptedStr: string, +): Promise<string | null> { + 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; + } +} diff --git a/package.json b/package.json index a516b6aab8f6589729b83b38c8bb42daebf8be0f..567613ebe0afc5d60a4d96b498ebeccd41ea615a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "generate": "run-s 'generate:*'", "generate:imports": "node tools/generate-imports.mjs", "git-check": "node tools/check-git-version.mjs", - "jest": "node tools/jest.mjs", + "jest": "GIT_ALLOW_PROTOCOL=file LOG_LEVEL=fatal node --experimental-vm-modules node_modules/jest/bin/jest.js --logHeapUsage", "lint": "run-s ls-lint type-check eslint prettier markdown-lint git-check doc-fence-check", "lint-fix": "run-s eslint-fix prettier-fix markdown-lint-fix", "ls-lint": "ls-lint", diff --git a/tools/jest.mjs b/tools/jest.mjs deleted file mode 100644 index 9a2c8830eb5a97ff7a3bf495a7cbabb6eaabdc10..0000000000000000000000000000000000000000 --- a/tools/jest.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { argv, env, version } from 'node:process'; -import semver from 'semver'; - -// needed for tests -env.GIT_ALLOW_PROTOCOL = 'file'; -// reduce logging -env.LOG_LEVEL = 'fatal'; - -const args = ['--experimental-vm-modules']; - -/* - * openpgp encryption is broken because it needs PKCS#1 v1.5 - * - #27375 - * - https://nodejs.org/en/blog/vulnerability/february-2024-security-releases#nodejs-is-vulnerable-to-the-marvin-attack-timing-variant-of-the-bleichenbacher-attack-against-pkcs1-v15-padding-cve-2023-46809---medium - * - * Sadly there is no way to suppress this warning: `SECURITY WARNING: Reverting CVE-2023-46809: Marvin attack on PKCS#1 padding` - */ -if (semver.satisfies(version, '^18.19.1 || ^20.11.1 || >=21.6.2')) { - args.push('--security-revert=CVE-2023-46809'); -} - -args.push('node_modules/jest/bin/jest.js', '--logHeapUsage'); - -// add other args after `node tools/jest.mjs` -args.push(...argv.slice(2)); - -const res = spawnSync('node', args, { stdio: 'inherit', env }); - -if (res.status !== null && res.status !== 0) { - process.exit(res.status); -}