diff --git a/lib/config/__fixtures__/private-ec.json b/lib/config/__fixtures__/private-ec.json new file mode 100644 index 00000000000000..1ac77edeec1b44 --- /dev/null +++ b/lib/config/__fixtures__/private-ec.json @@ -0,0 +1,7 @@ +{ + "kty":"EC", + "x":"pqwOZwVa3iiV1gnmsnHWYSYZgc37xH_FO_Ja0_F9SjFPiHUg-WLl6HEYzjIvls13", + "y":"fs_xLEuA1S3p47GY3GG2S_0YW7RTitKTOEbAnpXDaATzdbiA0RRvpRT_L5tt_qxG", + "crv":"P-384", + "d":"yDnv6FhAi5PXE2IRBh6UunGGkFTkJvbOJCXBh9A5rHon8VthWxh7QLEw9n7Wq539" +} diff --git a/lib/config/decrypt.spec.ts b/lib/config/decrypt.spec.ts index 30f3a83204b65b..1818f24260bc8d 100644 --- a/lib/config/decrypt.spec.ts +++ b/lib/config/decrypt.spec.ts @@ -1,11 +1,19 @@ +import crypto from 'node:crypto'; import { Fixtures } from '../../test/fixtures'; import { CONFIG_VALIDATION } from '../constants/error-messages'; -import { decryptConfig } from './decrypt'; +import { Json } from '../util/schema-utils'; +import { toBase64 } from '../util/string'; +import { decryptConfig, tryDecryptEcdhAesGcm } from './decrypt'; import { GlobalConfig } from './global'; +import { EcJwkPriv } from './schema'; import type { RenovateConfig } from './types'; const privateKey = Fixtures.get('private.pem'); const privateKeyPgp = Fixtures.get('private-pgp.pem'); +// Renovate private key +const privateEc = Fixtures.get('private-ec.json'); +const renovatePrivateKey = Json.pipe(EcJwkPriv).parse(privateEc); + const repository = 'abc/def'; describe('config/decrypt', () => { @@ -208,5 +216,92 @@ describe('config/decrypt', () => { CONFIG_VALIDATION, ); }); + + it('handles EC/AES-GCM encryption', async () => { + GlobalConfig.set({ privateKey: toBase64(privateEc) }); + config.encrypted = { + token: + 'eyJrIjp7Imt0eSI6IkVDIiwieCI6IlM0TFhnVUJHMUNlSXVUU1ZiZEg3MHZpSjV6dUxaRFNydFJ2ZUhVTHBBYnBMTXAwQ0tyeVozWm5RN1lKQjh3N2MiLCJ5IjoiZVpQNFZzT1hXZGxRYkNWYWxSakVWNEFzTFBxYmpKdW1mR1dfTzlZbG9HM2dqbzd5enJfUXQtbXl2SG5LdkZheiIsImNydiI6IlAtMzg0In0sImkiOiJNRDVMb2hUMmh2VS9SYXA3IiwibSI6IkptMERpUmNtbEFEU2JjeVdnWmJlUXl4QkdUbU55aVlkV2NDa0tyVnhQZStTdEdMMlBwWT0ifQ==', + }; + + const res = await decryptConfig(config, 'some/def'); + expect(res.encrypted).toBeUndefined(); + expect(res.token).toBe('123'); + await expect(decryptConfig(config, 'abc/defg')).rejects.toThrow( + CONFIG_VALIDATION, + ); + }); + }); + + describe('tryDecryptEc', () => { + it('decrypts', async () => { + // Generate a new key pair in the browser for each encryption, + // this private key will be thrown away after encryption, + // so that only Renovate can decrypt with the private key. + const browserKeyPair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-384' }, + false, + ['deriveKey'], + ); + + // `d` is the private key, `x` and `y` are the public key + const { d, ...renovatePublicKey } = { ...renovatePrivateKey }; + + // import Renovate's public key + const importedRenovatePublicKey = await crypto.subtle.importKey( + 'jwk', + renovatePublicKey, + { name: 'ECDH', namedCurve: 'P-384' }, + false, + [], + ); + + // derive shared secret from our private key and Renovate's public key + const pw = await crypto.subtle.deriveKey( + { name: 'ECDH', public: importedRenovatePublicKey }, + browserKeyPair.privateKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ); + + // always use a new initialization vector (IV), should always be 12 bytes + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // encrypt the message + const message = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + pw, + // Use TextEncoder instead of Buffer in browser + Buffer.from('{"o":"some","v":"123"}'), + // new TextEncoder().encode('{"o":"some","v":"123"}'), + ); + + // prepare the encrypted object + const encrypted = { + // send our public key + k: await crypto.subtle.exportKey('jwk', browserKeyPair.publicKey), + // send the IV + i: Buffer.from(iv).toString('base64'), + // send the encrypted message + m: Buffer.from(message).toString('base64'), + }; + + // encode the encrypted object for easier transport + const encCfg = toBase64(JSON.stringify(encrypted)); + + // print to update test case above + // console.error('encCfg', encCfg); + + // decrypt the message with Renovate's private key + const res = await tryDecryptEcdhAesGcm(renovatePrivateKey, encCfg); + expect(res).toBe('{"o":"some","v":"123"}'); + }); + + it('throws for invalid config', async () => { + await expect( + tryDecryptEcdhAesGcm(renovatePrivateKey, ''), + ).resolves.toBeNull(); + }); }); }); diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts index 4ea8ddcd0ac004..e4c6e4a846149b 100644 --- a/lib/config/decrypt.ts +++ b/lib/config/decrypt.ts @@ -7,9 +7,63 @@ import { regEx } from '../util/regex'; import { addSecretForSanitizing } from '../util/sanitize'; import { ensureTrailingSlash } from '../util/url'; import { GlobalConfig } from './global'; -import { DecryptedObject } from './schema'; +import { + DecryptedObject, + type EcJwkPriv, + EncodedEcJwkPriv, + EncryptedConfigString, +} from './schema'; import type { RenovateConfig } from './types'; +export async function tryDecryptEcdhAesGcm( + privateKey: EcJwkPriv, + encryptedStr: string, +): Promise { + try { + const privKey = await crypto.subtle.importKey( + 'jwk', + privateKey, + { name: 'ECDH', namedCurve: privateKey.crv }, + false, + ['deriveKey'], + ); + + const parsed = await EncryptedConfigString.safeParseAsync(encryptedStr); + + if (!parsed.success) { + const error = new Error('config-validation'); + error.validationError = `Could not parse encrypted config.`; + throw error; + } + + const pubKey = await crypto.subtle.importKey( + 'jwk', + parsed.data.k, + { name: 'ECDH', namedCurve: parsed.data.k.crv }, + false, + [], + ); + + const pw = await crypto.subtle.deriveKey( + { name: 'ECDH', public: pubKey }, + privKey, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'], + ); + const buff = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: parsed.data.i }, + pw, + parsed.data.m, + ); + return Buffer.from(buff).toString(); + } catch (err) { + logger.debug({ err }, 'Could not decrypt using ECDH/AES-GCM.'); + } + + return null; +} + export async function tryDecryptPgp( privateKey: string, encryptedStr: string, @@ -90,7 +144,13 @@ export async function tryDecrypt( repository: string, ): Promise { let decryptedStr: string | null = null; - if (privateKey?.startsWith('-----BEGIN PGP PRIVATE KEY BLOCK-----')) { + const pk = await EncodedEcJwkPriv.safeParseAsync(privateKey); + if (pk.success) { + const decryptedObjStr = await tryDecryptEcdhAesGcm(pk.data, encryptedStr); + if (decryptedObjStr) { + decryptedStr = validateDecryptedValue(decryptedObjStr, repository); + } + } else if (privateKey?.startsWith('-----BEGIN PGP PRIVATE KEY BLOCK-----')) { const decryptedObjStr = await tryDecryptPgp(privateKey, encryptedStr); if (decryptedObjStr) { decryptedStr = validateDecryptedValue(decryptedObjStr, repository); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index b3d99d2949cd9a..2c35a72789f5cb 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { Json } from '../util/schema-utils'; +import { fromBase64 } from '../util/string'; export const DecryptedObject = Json.pipe( z.object({ @@ -8,3 +9,37 @@ export const DecryptedObject = Json.pipe( v: z.string().optional(), }), ); + +/** + * EC JSON Web Key (public key) + * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#json_web_key + */ +export const EcJwkPub = z.object({ + kty: z.literal('EC'), + crv: z.enum(['P-256', 'P-384', 'P-521']), + x: z.string(), + y: z.string(), +}); + +/** + * EC JSON Web Key (private key) + * https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#json_web_key + */ +export const EcJwkPriv = EcJwkPub.extend({ + d: z.string(), +}); + +export type EcJwkPriv = z.infer; + +const Base64 = z.string().transform((v) => fromBase64(v)); + +export const EncodedEcJwkPriv = Base64.pipe(Json.pipe(EcJwkPriv)); + +export const EncryptedConfig = z.object({ + k: EcJwkPub, + i: z.string().transform((v) => new Uint8Array(Buffer.from(v, 'base64'))), + m: z.string().transform((v) => new Uint8Array(Buffer.from(v, 'base64'))), +}); +export type EncryptedConfig = z.infer; + +export const EncryptedConfigString = Base64.pipe(Json.pipe(EncryptedConfig));