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

Add support for constant-time decryption of PKCS#1 v1.5-encoded session keys #1445

Merged
merged 8 commits into from Jan 19, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions openpgp.d.ts
Expand Up @@ -326,6 +326,8 @@ interface Config {
versionString: string;
commentString: string;
allowInsecureDecryptionWithSigningKeys: boolean;
constantTimePKCS1Decryption: boolean;
constantTimePKCS1DecryptionSupportedSymmetricAlgorithms: Set<enums.symmetric>;
v5Keys: boolean;
preferredAEADAlgorithm: enums.aead;
aeadChunkSizeByte: number;
Expand Down
19 changes: 19 additions & 0 deletions src/config/config.js
Expand Up @@ -140,6 +140,25 @@ export default {
*/
allowInsecureVerificationWithReformattedKeys: false,

/**
* Enable constant-time decryption of RSA- and ElGamal-encrypted session keys, to hinder Bleichenbacher-like attacks (https://link.springer.com/chapter/10.1007/BFb0055716).
* This setting has measurable performance impact and it is only helpful in application scenarios where both of the following conditions apply:
* - new/incoming messages are automatically decrypted (without user interaction);
* - an attacker can determine how long it takes to decrypt each message (e.g. due to decryption errors being logged remotely).
* See also `constantTimePKCS1DecryptionSupportedSymmetricAlgorithms`.
* @memberof module:config
* @property {Boolean} constantTimePKCS1Decryption
*/
constantTimePKCS1Decryption: false,
/**
* This setting is only meaningful if `constantTimePKCS1Decryption` is enabled.
* Decryption of RSA- and ElGamal-encrypted session keys of symmetric algorithms different from the ones specified here will fail.
* However, the more algorithms are added, the slower the decryption procedure becomes.
* @memberof module:config
* @property {Set<Integer>} constantTimePKCS1DecryptionSupportedSymmetricAlgorithms {@link module:enums.symmetric}
*/
constantTimePKCS1DecryptionSupportedSymmetricAlgorithms: new Set([enums.symmetric.aes128, enums.symmetric.aes192, enums.symmetric.aes256]),

/**
* @memberof module:config
* @property {Integer} minBytesForWebCrypto The minimum amount of bytes for which to use native WebCrypto APIs when available
Expand Down
9 changes: 6 additions & 3 deletions src/crypto/crypto.js
Expand Up @@ -76,23 +76,26 @@ export async function publicKeyEncrypt(algo, publicParams, data, fingerprint) {
* @param {Object} privateKeyParams - Algorithm-specific private key parameters
* @param {Object} sessionKeyParams - Encrypted session key parameters
* @param {Uint8Array} fingerprint - Recipient fingerprint
* @param {Uint8Array} [randomPayload] - Data to return on decryption error, instead of throwing
* (needed for constant-time processing in RSA and ElGamal)
* @returns {Promise<Uint8Array>} Decrypted data.
* @throws {Error} on sensitive decryption error, unless `randomPayload` is given
* @async
*/
export async function publicKeyDecrypt(algo, publicKeyParams, privateKeyParams, sessionKeyParams, fingerprint) {
export async function publicKeyDecrypt(algo, publicKeyParams, privateKeyParams, sessionKeyParams, fingerprint, randomPayload) {
switch (algo) {
case enums.publicKey.rsaEncryptSign:
case enums.publicKey.rsaEncrypt: {
const { c } = sessionKeyParams;
const { n, e } = publicKeyParams;
const { d, p, q, u } = privateKeyParams;
return publicKey.rsa.decrypt(c, n, e, d, p, q, u);
return publicKey.rsa.decrypt(c, n, e, d, p, q, u, randomPayload);
}
case enums.publicKey.elgamal: {
const { c1, c2 } = sessionKeyParams;
const p = publicKeyParams.p;
const x = privateKeyParams.x;
return publicKey.elgamal.decrypt(c1, c2, p, x);
return publicKey.elgamal.decrypt(c1, c2, p, x, randomPayload);
}
case enums.publicKey.ecdh: {
const { oid, Q, kdfParams } = publicKeyParams;
Expand Down
32 changes: 23 additions & 9 deletions src/crypto/pkcs1.js
Expand Up @@ -26,6 +26,7 @@

import { getRandomBytes } from './random';
import hash from './hash';
import util from '../util';

/**
* ASN1 object identifiers for hashes
Expand Down Expand Up @@ -98,18 +99,31 @@ export async function emeEncode(message, keyLength) {
* Decode a EME-PKCS1-v1_5 padded message
* @see {@link https://tools.ietf.org/html/rfc4880#section-13.1.2|RFC 4880 13.1.2}
* @param {Uint8Array} encoded - Encoded message bytes
* @returns {Uint8Array} Message.
* @param {Uint8Array} randomPayload - Data to return in case of decoding error (needed for constant-time processing)
* @returns {Uint8Array} decoded data or `randomPayload` (on error, if given)
* @throws {Error} on decoding failure, unless `randomPayload` is provided
*/
export function emeDecode(encoded) {
let i = 2;
while (encoded[i] !== 0 && i < encoded.length) {
i++;
export function emeDecode(encoded, randomPayload) {
// encoded format: 0x00 0x02 <PS> 0x00 <payload>
let offset = 2;
let separatorNotFound = 1;
for (let j = offset; j < encoded.length; j++) {
separatorNotFound &= encoded[j] !== 0;
offset += separatorNotFound;
}
const psLen = i - 2;
const separator = encoded[i++];
if (encoded[0] === 0 && encoded[1] === 2 && psLen >= 8 && separator === 0) {
return encoded.subarray(i);

const psLen = offset - 2;
const payload = encoded.subarray(offset + 1); // discar the 0x00 separator
larabr marked this conversation as resolved.
Show resolved Hide resolved
const isValidPadding = encoded[0] === 0 & encoded[1] === 2 & psLen >= 8 & !separatorNotFound;

if (randomPayload) {
return util.selectUint8Array(isValidPadding, payload, randomPayload);
}

if (isValidPadding) {
return payload;
}

throw new Error('Decryption error');
}

Expand Down
7 changes: 5 additions & 2 deletions src/crypto/public_key/elgamal.js
Expand Up @@ -59,18 +59,21 @@ export async function encrypt(data, p, g, y) {
* @param {Uint8Array} c2
* @param {Uint8Array} p
* @param {Uint8Array} x
* @param {Uint8Array} randomPayload - Data to return on unpadding error, instead of throwing
* (needed for constant-time processing)
* @returns {Promise<Uint8Array>} Unpadded message.
* @throws {Error} on decryption error, unless `randomPayload` is given
* @async
*/
export async function decrypt(c1, c2, p, x) {
export async function decrypt(c1, c2, p, x, randomPayload) {
const BigInteger = await util.getBigInteger();
c1 = new BigInteger(c1);
c2 = new BigInteger(c2);
p = new BigInteger(p);
x = new BigInteger(x);

const padded = c1.modExp(x, p).modInv(p).imul(c2).imod(p);
return emeDecode(padded.toUint8Array('be', p.byteLength()));
return emeDecode(padded.toUint8Array('be', p.byteLength()), randomPayload);
}

/**
Expand Down
18 changes: 12 additions & 6 deletions src/crypto/public_key/rsa.js
Expand Up @@ -133,14 +133,17 @@ export async function encrypt(data, n, e) {
* @param {Uint8Array} p - RSA private prime p
* @param {Uint8Array} q - RSA private prime q
* @param {Uint8Array} u - RSA private coefficient
* @param {Uint8Array} randomPayload - Data to return on decryption error, instead of throwing
* (needed for constant-time processing)
* @returns {Promise<String>} RSA Plaintext.
* @throws {Error} on decryption error, unless `randomPayload` is given
* @async
*/
export async function decrypt(data, n, e, d, p, q, u) {
export async function decrypt(data, n, e, d, p, q, u, randomPayload) {
if (util.getNodeCrypto()) {
return nodeDecrypt(data, n, e, d, p, q, u);
return nodeDecrypt(data, n, e, d, p, q, u, randomPayload);
}
return bnDecrypt(data, n, e, d, p, q, u);
return bnDecrypt(data, n, e, d, p, q, u, randomPayload);
}

/**
Expand Down Expand Up @@ -439,7 +442,7 @@ async function bnEncrypt(data, n, e) {
return data.modExp(e, n).toUint8Array('be', n.byteLength());
}

async function nodeDecrypt(data, n, e, d, p, q, u) {
async function nodeDecrypt(data, n, e, d, p, q, u, randomPayload) {
const { default: BN } = await import('bn.js');

const pBNum = new BN(p);
Expand Down Expand Up @@ -473,11 +476,14 @@ async function nodeDecrypt(data, n, e, d, p, q, u) {
try {
return new Uint8Array(nodeCrypto.privateDecrypt(key, data));
} catch (err) {
if (randomPayload) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is not ideal, but I don't think we can do better than this

return randomPayload;
}
throw new Error('Decryption error');
}
}

async function bnDecrypt(data, n, e, d, p, q, u) {
async function bnDecrypt(data, n, e, d, p, q, u, randomPayload) {
const BigInteger = await util.getBigInteger();
data = new BigInteger(data);
n = new BigInteger(n);
Expand Down Expand Up @@ -506,7 +512,7 @@ async function bnDecrypt(data, n, e, d, p, q, u) {
result = result.mul(unblinder).mod(n);


return emeDecode(result.toUint8Array('be', n.byteLength()));
return emeDecode(result.toUint8Array('be', n.byteLength()), randomPayload);
}

/** Convert Openpgp private key params to jwk key according to
Expand Down
96 changes: 68 additions & 28 deletions src/message.js
Expand Up @@ -107,7 +107,7 @@ export class Message {
* @async
*/
async decrypt(decryptionKeys, passwords, sessionKeys, date = new Date(), config = defaultConfig) {
const sessionKeyObjs = sessionKeys || await this.decryptSessionKeys(decryptionKeys, passwords, date, config);
const sessionKeyObjects = sessionKeys || await this.decryptSessionKeys(decryptionKeys, passwords, date, config);

const symEncryptedPacketlist = this.packets.filterByTag(
enums.packet.symmetricallyEncryptedData,
Expand All @@ -121,7 +121,7 @@ export class Message {

const symEncryptedPacket = symEncryptedPacketlist[0];
let exception = null;
const decryptedPromise = Promise.all(sessionKeyObjs.map(async ({ algorithm: algorithmName, data }) => {
const decryptedPromise = Promise.all(sessionKeyObjects.map(async ({ algorithm: algorithmName, data }) => {
if (!util.isUint8Array(data) || !util.isString(algorithmName)) {
throw new Error('Invalid session key for decryption.');
}
Expand Down Expand Up @@ -162,36 +162,36 @@ export class Message {
* @async
*/
async decryptSessionKeys(decryptionKeys, passwords, date = new Date(), config = defaultConfig) {
let keyPackets = [];
let decryptedSessionKeyPackets = [];

let exception;
if (passwords) {
const symESKeyPacketlist = this.packets.filterByTag(enums.packet.symEncryptedSessionKey);
if (symESKeyPacketlist.length === 0) {
const skeskPackets = this.packets.filterByTag(enums.packet.symEncryptedSessionKey);
if (skeskPackets.length === 0) {
throw new Error('No symmetrically encrypted session key packet found.');
}
await Promise.all(passwords.map(async function(password, i) {
let packets;
if (i) {
packets = await PacketList.fromBinary(symESKeyPacketlist.write(), allowedSymSessionKeyPackets, config);
packets = await PacketList.fromBinary(skeskPackets.write(), allowedSymSessionKeyPackets, config);
} else {
packets = symESKeyPacketlist;
packets = skeskPackets;
}
await Promise.all(packets.map(async function(keyPacket) {
await Promise.all(packets.map(async function(skeskPacket) {
try {
await keyPacket.decrypt(password);
keyPackets.push(keyPacket);
await skeskPacket.decrypt(password);
decryptedSessionKeyPackets.push(skeskPacket);
} catch (err) {
util.printDebugError(err);
}
}));
}));
} else if (decryptionKeys) {
const pkESKeyPacketlist = this.packets.filterByTag(enums.packet.publicKeyEncryptedSessionKey);
if (pkESKeyPacketlist.length === 0) {
const pkeskPackets = this.packets.filterByTag(enums.packet.publicKeyEncryptedSessionKey);
if (pkeskPackets.length === 0) {
throw new Error('No public key encrypted session key packet found.');
}
await Promise.all(pkESKeyPacketlist.map(async function(keyPacket) {
await Promise.all(pkeskPackets.map(async function(pkeskPacket) {
await Promise.all(decryptionKeys.map(async function(decryptionKey) {
let algos = [
enums.symmetric.aes256, // Old OpenPGP.js default fallback
Expand All @@ -207,38 +207,78 @@ export class Message {
} catch (e) {}

// do not check key expiration to allow decryption of old messages
const decryptionKeyPackets = (await decryptionKey.getDecryptionKeys(keyPacket.publicKeyID, null, undefined, config)).map(key => key.keyPacket);
const decryptionKeyPackets = (await decryptionKey.getDecryptionKeys(pkeskPacket.publicKeyID, null, undefined, config)).map(key => key.keyPacket);
await Promise.all(decryptionKeyPackets.map(async function(decryptionKeyPacket) {
if (!decryptionKeyPacket || decryptionKeyPacket.isDummy()) {
return;
}
if (!decryptionKeyPacket.isDecrypted()) {
throw new Error('Decryption key is not decrypted.');
}
try {
await keyPacket.decrypt(decryptionKeyPacket);
if (!algos.includes(keyPacket.sessionKeyAlgorithm)) {
throw new Error('A non-preferred symmetric algorithm was used.');

// To hinder CCA attacks against PKCS1, we carry out a constant-time decryption flow if the `constantTimePKCS1Decryption` config option is set.
const doConstantTimeDecryption = config.constantTimePKCS1Decryption && (
pkeskPacket.publicKeyAlgorithm === enums.publicKey.rsaEncrypt ||
pkeskPacket.publicKeyAlgorithm === enums.publicKey.rsaEncryptSign ||
pkeskPacket.publicKeyAlgorithm === enums.publicKey.rsaSign ||
pkeskPacket.publicKeyAlgorithm === enums.publicKey.elgamal
);

if (doConstantTimeDecryption) {
// The goal is to not reveal whether PKESK decryption (specifically the PKCS1 decoding step) failed, hence, we always proceed to decrypt the message,
// either with the successfully decrypted session key, or with a randomly generated one.
// Since the SEIP/AEAD's symmetric algorithm and key size are stored in the encrypted portion of the PKESK, and the execution flow cannot depend on
// the decrypted payload, we always assume the message to be encrypted with one of the symmetric algorithms specified in `config.constantTimePKCS1DecryptionSupportedSymmetricAlgorithms`:
// - If the PKESK decryption succeeds, and the session key cipher is in the supported set, then we try to decrypt the data with the decrypted session key as well as with the
// randomly generated keys of the remaining key types.
// - If the PKESK decryptions fails, or if it succeeds but support for the cipher is not enabled, then we discard the session key and try to decrypt the data using only the randomly
// generated session keys.
// NB: as a result, if the data is encrypted with a non-suported cipher, decryption will always fail.

const serialisedPKESK = pkeskPacket.write(); // make copies to be able to decrypt the PKESK packet multiple times
await Promise.all(Array.from(config.constantTimePKCS1DecryptionSupportedSymmetricAlgorithms).map(async sessionKeyAlgorithm => {
const pkeskPacketCopy = new PublicKeyEncryptedSessionKeyPacket();
pkeskPacketCopy.read(serialisedPKESK);
const randomSessionKey = {
sessionKeyAlgorithm,
sessionKey: await crypto.generateSessionKey(sessionKeyAlgorithm)
};
try {
await pkeskPacketCopy.decrypt(decryptionKeyPacket, randomSessionKey);
decryptedSessionKeyPackets.push(pkeskPacketCopy);
} catch (err) {
// `decrypt` can still throw some non-security-sensitive errors
util.printDebugError(err);
exception = err;
}
}));

} else {
try {
await pkeskPacket.decrypt(decryptionKeyPacket);
if (!algos.includes(enums.write(enums.symmetric, pkeskPacket.sessionKeyAlgorithm))) {
throw new Error('A non-preferred symmetric algorithm was used.');
}
decryptedSessionKeyPackets.push(pkeskPacket);
} catch (err) {
util.printDebugError(err);
exception = err;
}
keyPackets.push(keyPacket);
} catch (err) {
util.printDebugError(err);
exception = err;
}
}));
}));
stream.cancel(keyPacket.encrypted); // Don't keep copy of encrypted data in memory.
keyPacket.encrypted = null;
stream.cancel(pkeskPacket.encrypted); // Don't keep copy of encrypted data in memory.
pkeskPacket.encrypted = null;
}));
} else {
throw new Error('No key or password specified.');
}

if (keyPackets.length) {
if (decryptedSessionKeyPackets.length > 0) {
// Return only unique session keys
if (keyPackets.length > 1) {
if (decryptedSessionKeyPackets.length > 1) {
const seen = new Set();
keyPackets = keyPackets.filter(item => {
decryptedSessionKeyPackets = decryptedSessionKeyPackets.filter(item => {
const k = item.sessionKeyAlgorithm + util.uint8ArrayToString(item.sessionKey);
if (seen.has(k)) {
return false;
Expand All @@ -248,7 +288,7 @@ export class Message {
});
}

return keyPackets.map(packet => ({
return decryptedSessionKeyPackets.map(packet => ({
data: packet.sessionKey,
algorithm: enums.read(enums.symmetric, packet.sessionKeyAlgorithm)
}));
Expand Down