Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ zeroize = { version = "1.8", features = ["alloc"] }

# optional dependencies
crypto-common = { version = "0.2.0-rc.8", optional = true, features = ["getrandom"] }
hmac = { version = "0.13.0-rc.0", optional = true, default-features = false }
pkcs1 = { version = "0.8.0-rc.3", optional = true, default-features = false, features = ["alloc", "pem"] }
pkcs8 = { version = "0.11.0-rc.8", optional = true, default-features = false, features = ["alloc", "pem"] }
serdect = { version = "0.4", optional = true }
Expand Down Expand Up @@ -58,6 +59,10 @@ getrandom = ["crypto-bigint/getrandom", "crypto-common"]
serde = ["encoding", "dep:serde", "dep:serdect", "crypto-bigint/serde"]
pkcs5 = ["pkcs8/encryption"]
std = ["pkcs1?/std", "pkcs8?/std"]
sha1 = ["dep:sha1"]
sha2 = ["dep:sha2"]
# Implicit rejection for PKCS#1 v1.5 decryption (Marvin attack mitigation)
implicit-rejection = ["dep:hmac", "sha2"]

[package.metadata.docs.rs]
features = ["std", "serde", "hazmat", "sha2"]
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ See the [open security issues] on our issue tracker for other known problems.

~~Notably the implementation of [modular exponentiation is not constant time],
but timing variability is masked using [random blinding], a commonly used
technique.~~ This crate is vulnerable to the [Marvin Attack] which could enable
private key recovery by a network attacker (see [RUSTSEC-2023-0071]).
technique.~~ ~~This crate is vulnerable to the [Marvin Attack] which could enable
private key recovery by a network attacker (see [RUSTSEC-2023-0071])~~.

You can follow our work on mitigating this issue in [#390].

Expand Down
132 changes: 132 additions & 0 deletions src/algorithms/pkcs1v15.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ use zeroize::Zeroizing;

use crate::errors::{Error, Result};

#[cfg(feature = "implicit-rejection")]
use digest::KeyInit;
#[cfg(feature = "implicit-rejection")]
use hmac::{Hmac, Mac};
#[cfg(feature = "implicit-rejection")]
use sha2::Sha256;

/// Fills the provided slice with random values, which are guaranteed
/// to not be zero.
#[inline]
Expand Down Expand Up @@ -116,6 +123,131 @@ fn decrypt_inner(em: Vec<u8>, k: usize) -> Result<(u8, Vec<u8>, u32)> {
Ok((valid.to_u8(), em, index))
}

/// Implicit Rejection PRF as specified in IETF draft-irtf-cfrg-rsa-guidance.
///
/// Generates a deterministic synthetic plaintext from the ciphertext and private key
/// when padding validation fails. This prevents timing side-channels.
///
/// PRF(key, label || ciphertext) where:
/// - key = HMAC-SHA256(d || p || q, "implicit rejection key")
/// - label = "implicit rejection PKCS#1 v1.5 ciphertext"
#[cfg(feature = "implicit-rejection")]
pub(crate) fn implicit_rejection_prf(
key_hash: &[u8; 32],
ciphertext: &[u8],
output_len: usize,
) -> Vec<u8> {
const LABEL: &[u8] = b"implicit rejection PKCS#1 v1.5 ciphertext";

// Use HMAC-SHA256 in counter mode to generate enough output bytes
let mut result = Vec::with_capacity(output_len);
let mut counter: u32 = 0;

while result.len() < output_len {
let mut mac =
Hmac::<Sha256>::new_from_slice(key_hash).expect("HMAC can accept any key length");
mac.update(&counter.to_be_bytes());
mac.update(LABEL);
mac.update(ciphertext);
let block = mac.finalize().into_bytes();

let remaining = output_len - result.len();
let take = core::cmp::min(remaining, block.len());
result.extend_from_slice(&block[..take]);
counter = counter
.checked_add(1)
.expect("implicit rejection PRF counter overflow");
}

result
}

/// Derive a key for implicit rejection PRF from the private key components.
///
/// key = HMAC-SHA256(d || p || q, "implicit rejection key")
#[cfg(feature = "implicit-rejection")]
pub(crate) fn derive_implicit_rejection_key(d: &[u8], primes: &[&[u8]]) -> [u8; 32] {
const KEY_LABEL: &[u8] = b"implicit rejection key";

// Concatenate d and all primes as the HMAC key material
let mut key_material = Zeroizing::new(Vec::with_capacity(
d.len() + primes.iter().map(|p| p.len()).sum::<usize>(),
));
key_material.extend_from_slice(d);
for prime in primes {
key_material.extend_from_slice(prime);
}

let mut mac = Hmac::<Sha256>::new_from_slice(key_material.as_ref())
.expect("HMAC can accept any key length");
mac.update(KEY_LABEL);
let result = mac.finalize().into_bytes();

let mut key = [0u8; 32];
key.copy_from_slice(&result);
key
}

/// Removes the encryption padding scheme from PKCS#1 v1.5 with implicit rejection.
///
/// Instead of returning an error on invalid padding, this function returns a
/// deterministic synthetic message derived from the ciphertext. This prevents
/// Bleichenbacher/Marvin timing attacks.
///
/// # Arguments
/// * `em` - The decrypted (but still padded) message
/// * `k` - The key size in bytes
/// * `ciphertext` - The original ciphertext (used to derive synthetic message)
/// * `key_hash` - Pre-computed HMAC key derived from private key
/// * `expected_len` - The expected plaintext length
///
/// # Returns
/// Either the actual plaintext or a synthetic plaintext of `expected_len` bytes
#[cfg(feature = "implicit-rejection")]
#[inline]
pub(crate) fn pkcs1v15_encrypt_unpad_implicit_rejection(
em: Vec<u8>,
k: usize,
ciphertext: &[u8],
key_hash: &[u8; 32],
expected_len: usize,
) -> Vec<u8> {
// Generate synthetic message first (constant time - always computed)
let synthetic = implicit_rejection_prf(key_hash, ciphertext, expected_len);

// Validate padding in constant time
let (valid, decrypted, index) = match decrypt_inner(em, k) {
Ok(result) => result,
Err(_) => {
// If k < 11, return synthetic (this is a non-timing-sensitive check)
return synthetic;
}
};

// Check if the message length matches expected_len
let msg_len = k.saturating_sub(index as usize);
let len_matches = Choice::from_u8_lsb((msg_len == expected_len) as u8);

// Combine validity: padding must be valid AND length must match
let use_real = Choice::from_u8_lsb(valid) & len_matches;

// Constant-time selection between real and synthetic message
let mut result = vec![0u8; expected_len];
let decrypted_len = decrypted.len();
debug_assert!(decrypted_len > 0);
for (i, out_byte) in result.iter_mut().enumerate() {
// Use branchless, in-bounds indexing to avoid timing side channels.
// For valid messages, index as usize + i < decrypted_len, so the modulo
// does not change the effective index.
let idx = (index as usize + i) % decrypted_len;
let real_byte = decrypted[idx];
let synthetic_byte = synthetic[i];
*out_byte = u8::ct_select(&synthetic_byte, &real_byte, use_real);
}

result
}

#[inline]
pub(crate) fn pkcs1v15_sign_pad(prefix: &[u8], hashed: &[u8], k: usize) -> Result<Vec<u8>> {
let hash_len = hashed.len();
Expand Down
Loading