Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(config): support modern ECDH/AES-GCM encryption #27416

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/config/__fixtures__/private-ec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kty":"EC",
"x":"pqwOZwVa3iiV1gnmsnHWYSYZgc37xH_FO_Ja0_F9SjFPiHUg-WLl6HEYzjIvls13",
"y":"fs_xLEuA1S3p47GY3GG2S_0YW7RTitKTOEbAnpXDaATzdbiA0RRvpRT_L5tt_qxG",
"crv":"P-384",
"d":"yDnv6FhAi5PXE2IRBh6UunGGkFTkJvbOJCXBh9A5rHon8VthWxh7QLEw9n7Wq539"
}
97 changes: 96 additions & 1 deletion lib/config/decrypt.spec.ts
Original file line number Diff line number Diff line change
@@ -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
viceice marked this conversation as resolved.
Show resolved Hide resolved
const privateEc = Fixtures.get('private-ec.json');
const renovatePrivateKey = Json.pipe(EcJwkPriv).parse(privateEc);

const repository = 'abc/def';

describe('config/decrypt', () => {
Expand Down Expand Up @@ -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
viceice marked this conversation as resolved.
Show resolved Hide resolved
const { d, ...renovatePublicKey } = { ...renovatePrivateKey };

// import Renovate's public key
const svrPubKey = await crypto.subtle.importKey(
viceice marked this conversation as resolved.
Show resolved Hide resolved
'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(
Copy link
Collaborator

@Churro Churro Feb 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks cryptographically problematic to me.

The shared secret derived by an ECDH key agreement is not uniformly random. deriveKey doesn't perform any key stretching or hashing on the shared secret. It is basically the same as calling deriveBits to get a byte array and then passing it to importKey to use as a symmetric key with AES. P-384 generates more output bits than needed, some part will simply be dropped to fit the 256 bits needed for AES.

To derive a cryptographically strong key for AES-GCM, the shared secret needs to be processed via HKDF. An example how to do this can be found here: https://stackoverflow.com/a/67942717/589259
Note that this example uses an empty salt. I'd say it makes sense to use a salt though, as it ensures that reusing the same shared secret for two operations will still yield different encryption keys. I'd suggest prepending it to the ciphertext.

Also see:

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 didn't know that. do we need a separate value for salt, or can we reuse the aes-gcm iv?
We should try to make the encypted object as small as possible but of cause as secure as possible.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Salt and IV are similar in some ways but also need to fulfill different requirements depending on what you want to achieve. I'd suggest to not simply reuse the same values. What you could do in this particular case:

Call deriveBits({ name: "HKDF", ... (as shown in the stackoverflow example above) and produce bits for both the key and the VI. You can use the iv (as it is currently named in your code) as a salt value for the HKDF. When calling deriveBits, increase the length param by the 96 bits you need for the IV, so 256 + 96 bits overall, then use the trailing 96 bits for the IV.

This way you don't need to prepend it to the ciphertext and the HKDF ensures it's uniformly random for use with a symmetric cipher like AES. If you change the salt, the encryption key and IV will change as well. This is generally desirable because it ensures that if you encrypt multiple texts (with different salt values in the HKDF call), a unique encryption key and IV will be used as well.

{ name: 'ECDH', public: svrPubKey },
viceice marked this conversation as resolved.
Show resolved Hide resolved
browserKeyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);

// always use a new IV, should always be 12 bytes
viceice marked this conversation as resolved.
Show resolved Hide resolved
const iv = crypto.getRandomValues(new Uint8Array(12));
viceice marked this conversation as resolved.
Show resolved Hide resolved

// 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);
viceice marked this conversation as resolved.
Show resolved Hide resolved

// decrypt the message with renovate private key
viceice marked this conversation as resolved.
Show resolved Hide resolved
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();
});
});
});
64 changes: 62 additions & 2 deletions lib/config/decrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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');
viceice marked this conversation as resolved.
Show resolved Hide resolved
}

return null;
}

export async function tryDecryptPgp(
privateKey: string,
encryptedStr: string,
Expand Down Expand Up @@ -90,7 +144,13 @@ export async function tryDecrypt(
repository: string,
): Promise<string | null> {
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);
Expand Down
35 changes: 35 additions & 0 deletions lib/config/schema.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const EcJwkPriv = EcJwkPub.extend({
const EcJwkPriv = EcJwkPub.extend({

d: z.string(),
});

export type EcJwkPriv = z.infer<typeof EcJwkPriv>;

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<typeof EncryptedConfig>;

export const EncryptedConfigString = Base64.pipe(Json.pipe(EncryptedConfig));
Loading