diff --git a/Cargo.lock b/Cargo.lock index a14f55ad..76ecdb3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "digest", "hex", "hex-literal", + "hmac", "pkcs1", "pkcs8", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 4d4974e6..38140fe2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } @@ -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"] diff --git a/README.md b/README.md index 85c20f62..0bfe777e 100644 --- a/README.md +++ b/README.md @@ -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]. diff --git a/src/algorithms/pkcs1v15.rs b/src/algorithms/pkcs1v15.rs index 9ccceaa5..9245c769 100644 --- a/src/algorithms/pkcs1v15.rs +++ b/src/algorithms/pkcs1v15.rs @@ -15,6 +15,13 @@ use zeroize::Zeroizing; use crate::errors::{Error, Result}; +#[cfg(feature = "implicit-rejection")] +use { + digest::KeyInit, + hmac::{Hmac, Mac}, + sha2::Sha256 +}; + /// Fills the provided slice with random values, which are guaranteed /// to not be zero. #[inline] @@ -116,6 +123,131 @@ fn decrypt_inner(em: Vec, k: usize) -> Result<(u8, Vec, 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 { + 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::::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::(), + )); + key_material.extend_from_slice(d); + for prime in primes { + key_material.extend_from_slice(prime); + } + + let mut mac = Hmac::::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, + k: usize, + ciphertext: &[u8], + key_hash: &[u8; 32], + expected_len: usize, +) -> Vec { + // 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> { let hash_len = hashed.len(); diff --git a/src/pkcs1v15.rs b/src/pkcs1v15.rs index 62a08dc1..a6d3ca77 100644 --- a/src/pkcs1v15.rs +++ b/src/pkcs1v15.rs @@ -160,7 +160,67 @@ fn decrypt( let em = rsa_decrypt_and_check(priv_key, rng, &ciphertext)?; let em = uint_to_zeroizing_be_pad(em, priv_key.size())?; - pkcs1v15_encrypt_unpad(em, priv_key.size()) + pkcs1v15_encrypt_unpad(em.to_vec(), priv_key.size()) +} + +/// Decrypts a plaintext using RSA and PKCS#1 v1.5 padding with implicit rejection. +/// +/// This function implements the implicit rejection mechanism as described in +/// [IETF draft-irtf-cfrg-rsa-guidance](https://datatracker.ietf.org/doc/draft-irtf-cfrg-rsa-guidance/). +/// Instead of returning an error on invalid padding, it returns a deterministic +/// synthetic plaintext derived from the ciphertext, preventing Bleichenbacher/Marvin +/// timing attacks. +/// +/// # Arguments +/// * `rng` - Optional RNG for RSA blinding (recommended for additional side-channel protection) +/// * `priv_key` - The RSA private key +/// * `ciphertext` - The ciphertext to decrypt +/// * `expected_len` - The expected length of the plaintext (e.g., 48 for TLS premaster secret) +/// +/// # Returns +/// Either the actual plaintext (if padding was valid and length matches) or a +/// synthetic plaintext of `expected_len` bytes. +#[cfg(feature = "implicit-rejection")] +#[inline] +pub(crate) fn decrypt_implicit_rejection( + rng: Option<&mut R>, + priv_key: &RsaPrivateKey, + ciphertext: &[u8], + expected_len: usize, +) -> Result> { + use crate::algorithms::pad::uint_to_be_pad; + use crate::traits::PrivateKeyParts; + + key::check_public(priv_key)?; + + // Derive the implicit rejection key from the private key components + let d_bytes = uint_to_be_pad(priv_key.d().clone(), priv_key.size())?; + let prime_bytes: Vec> = priv_key + .primes() + .iter() + .map(|p| { + let bits = p.bits(); + let byte_len = ((bits + 7) / 8) as usize; + uint_to_be_pad(p.clone(), byte_len) + }) + .collect::>>()?; + let prime_refs: Vec<&[u8]> = prime_bytes.iter().map(|v| v.as_slice()).collect(); + + let key_hash = derive_implicit_rejection_key(&d_bytes, &prime_refs); + + // Perform RSA decryption with optional blinding for additional side-channel protection + let ciphertext_uint = BoxedUint::from_be_slice(ciphertext, priv_key.n_bits_precision())?; + let em = rsa_decrypt_and_check(priv_key, rng, &ciphertext_uint)?; + let em = uint_to_zeroizing_be_pad(em, priv_key.size())?; + + // Use implicit rejection unpadding + Ok(pkcs1v15_encrypt_unpad_implicit_rejection( + em.to_vec(), + priv_key.size(), + ciphertext, + &key_hash, + expected_len, + )) } /// Calculates the signature of hashed using @@ -652,4 +712,372 @@ mod tests { .verify_prehash(msg, &Signature::try_from(expected_sig.as_slice()).unwrap()) .expect("failed to verify"); } + + #[cfg(feature = "implicit-rejection")] + mod implicit_rejection_tests { + use super::*; + use crate::traits::ImplicitRejectionDecryptor; + + #[test] + fn test_implicit_rejection_valid_ciphertext() { + // Test that valid ciphertext decrypts correctly with implicit rejection + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b"hello world"; + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + let decrypted = decrypting_key + .decrypt_implicit_rejection(&ciphertext, plaintext.len()) + .unwrap(); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_implicit_rejection_invalid_ciphertext() { + // Test that invalid ciphertext returns synthetic message (not an error) + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + // Create an invalid ciphertext (random garbage) + let invalid_ciphertext = vec![0x42u8; priv_key.size()]; + let expected_len = 48; // TLS premaster secret length + + // Should NOT return an error - that would leak timing information + let result = + decrypting_key.decrypt_implicit_rejection(&invalid_ciphertext, expected_len); + assert!(result.is_ok()); + + let synthetic = result.unwrap(); + assert_eq!(synthetic.len(), expected_len); + } + + #[test] + fn test_implicit_rejection_deterministic() { + // Test that the same invalid ciphertext always produces the same synthetic message + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid_ciphertext = vec![0x42u8; priv_key.size()]; + let expected_len = 48; + + let result1 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, expected_len) + .unwrap(); + let result2 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, expected_len) + .unwrap(); + + assert_eq!( + result1, result2, + "Synthetic message should be deterministic" + ); + } + + #[test] + fn test_implicit_rejection_different_ciphertexts() { + // Test that different invalid ciphertexts produce different synthetic messages + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid_ciphertext1 = vec![0x42u8; priv_key.size()]; + let invalid_ciphertext2 = vec![0x43u8; priv_key.size()]; + let expected_len = 48; + + let result1 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext1, expected_len) + .unwrap(); + let result2 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext2, expected_len) + .unwrap(); + + assert_ne!( + result1, result2, + "Different ciphertexts should produce different synthetic messages" + ); + } + + #[test] + fn test_implicit_rejection_length_mismatch() { + // Test that valid padding but wrong length returns synthetic message + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b"hello"; // 5 bytes + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + + // Request different length than actual plaintext + let result = decrypting_key + .decrypt_implicit_rejection(&ciphertext, 48) // Request 48 bytes, not 5 + .unwrap(); + + // Should get synthetic message of requested length + assert_eq!(result.len(), 48); + // Should NOT be the original plaintext (padded or otherwise) + assert_ne!(&result[..5], plaintext); + } + + #[test] + fn test_implicit_rejection_blinded() { + // Test that blinded decryption works correctly + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b"hello world"; + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + + // Blinded decryption should produce same result as non-blinded + let result_blinded = decrypting_key + .decrypt_implicit_rejection_blinded(&mut rng, &ciphertext, plaintext.len()) + .unwrap(); + + assert_eq!(result_blinded, plaintext); + } + + #[test] + fn test_implicit_rejection_blinded_invalid() { + // Test that blinded decryption of invalid ciphertext returns synthetic message + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid_ciphertext = vec![0x42u8; priv_key.size()]; + let expected_len = 48; + + // Blinded and non-blinded should produce same synthetic message + // (since synthetic is derived from ciphertext, not affected by blinding) + let result_blinded = decrypting_key + .decrypt_implicit_rejection_blinded(&mut rng, &invalid_ciphertext, expected_len) + .unwrap(); + let result_non_blinded = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, expected_len) + .unwrap(); + + assert_eq!(result_blinded.len(), expected_len); + assert_eq!(result_blinded, result_non_blinded); + } + } + + /// Test vectors based on IETF draft-irtf-cfrg-rsa-guidance-06 Appendix B + /// behavior (not exact vectors due to key dependency). + /// + #[cfg(all(test, feature = "implicit-rejection"))] + mod ietf_behavior_tests { + use super::*; + use crate::traits::ImplicitRejectionDecryptor; + + /// Test valid ciphertext - should return correct plaintext + #[test] + fn test_valid_ciphertext_decrypts_correctly() { + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + // Test with various message lengths like IETF B.1.2 + let plaintext = b"lorem ipsum dolor sit amet"; + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + let result = decrypting_key + .decrypt_implicit_rejection(&ciphertext, plaintext.len()) + .unwrap(); + + assert_eq!(result, plaintext); + } + + /// Test empty message - like IETF B.1.3 + #[test] + fn test_valid_empty_message() { + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b""; + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + let result = decrypting_key + .decrypt_implicit_rejection(&ciphertext, 0) + .unwrap(); + + assert_eq!(result.len(), 0); + } + + /// Test invalid ciphertext returns synthetic - like IETF B.1.5 + #[test] + fn test_invalid_ciphertext_returns_synthetic() { + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + // Random invalid ciphertext + let invalid_ciphertext = vec![0x42u8; priv_key.size()]; + + // Should NOT return error - that would leak information + let result = decrypting_key.decrypt_implicit_rejection(&invalid_ciphertext, 0); + assert!(result.is_ok()); + } + + /// Test that synthetic message is deterministic - critical for security + /// Referenced in IETF Section 7.3 Security Analysis + #[test] + fn test_synthetic_is_deterministic() { + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid_ciphertext = vec![0x42u8; priv_key.size()]; + + let result1 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, 11) + .unwrap(); + let result2 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, 11) + .unwrap(); + let result3 = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, 11) + .unwrap(); + + assert_eq!(result1, result2); + assert_eq!(result2, result3); + } + + /// Test different invalid ciphertexts produce different synthetic messages + /// This ensures the PRF is working correctly + #[test] + fn test_different_ciphertexts_different_synthetics() { + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid1 = vec![0x42u8; priv_key.size()]; + let invalid2 = vec![0x43u8; priv_key.size()]; + let invalid3 = vec![0x44u8; priv_key.size()]; + + let result1 = decrypting_key + .decrypt_implicit_rejection(&invalid1, 11) + .unwrap(); + let result2 = decrypting_key + .decrypt_implicit_rejection(&invalid2, 11) + .unwrap(); + let result3 = decrypting_key + .decrypt_implicit_rejection(&invalid3, 11) + .unwrap(); + + assert_ne!(result1, result2); + assert_ne!(result2, result3); + assert_ne!(result1, result3); + } + + /// Test wrong expected length returns synthetic - like IETF behavior + /// When correct padding but wrong length, should return synthetic + #[test] + fn test_wrong_length_returns_synthetic() { + let mut rng = ChaCha8Rng::from_seed([42; 32]); + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b"hello"; // 5 bytes + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key); + + // Request wrong length + let result = decrypting_key + .decrypt_implicit_rejection(&ciphertext, 48) + .unwrap(); + + assert_eq!(result.len(), 48); + assert_ne!(&result[..5], plaintext); + } + + /// Test that ciphertext starting with zero byte still works + /// Like IETF B.1.4 + #[test] + fn test_ciphertext_leading_zero() { + // Generate ciphertexts until we get one starting with 0x00 + let priv_key = get_private_key(); + let pub_key: RsaPublicKey = priv_key.clone().into(); + + let plaintext = b"test message"; + + // Try to find a ciphertext starting with 0x00 (rare but possible) + // If not found, just ensure we can decrypt normally + for seed_offset in 0u8..=255 { + let mut rng = ChaCha8Rng::from_seed([seed_offset; 32]); + let ciphertext = encrypt(&mut rng, &pub_key, plaintext).unwrap(); + + let decrypting_key = DecryptingKey::new(priv_key.clone()); + let result = decrypting_key + .decrypt_implicit_rejection(&ciphertext, plaintext.len()) + .unwrap(); + + assert_eq!(result, plaintext); + + // If we found one starting with 0x00, we've tested the edge case + if ciphertext[0] == 0x00 { + break; + } + } + } + + /// Test various synthetic message lengths + #[test] + fn test_various_synthetic_lengths() { + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + let invalid_ciphertext = vec![0x55u8; priv_key.size()]; + + // Test different lengths like IETF test vectors cover + for len in [0, 1, 10, 11, 26, 48, 100, 200] { + let result = decrypting_key + .decrypt_implicit_rejection(&invalid_ciphertext, len) + .unwrap(); + assert_eq!(result.len(), len); + } + } + + /// Test that API matches IETF Section 8 "Safe API" requirements + /// No errors returned for invalid padding, only for publicly invalid (e.g., ciphertext too large) + #[test] + fn test_safe_api_no_padding_errors() { + let priv_key = get_private_key(); + let decrypting_key = DecryptingKey::new(priv_key.clone()); + + // Various "invalid" ciphertexts that would fail padding checks + // Note: Ciphertexts must be < N (the modulus), so we use values that + // are valid RSA inputs but will have invalid PKCS#1 v1.5 padding after decryption. + // Values like 0x00...00 or very large values may be rejected before decryption + // as a public check (not a timing side-channel). + let test_cases: Vec> = vec![ + vec![0x42; priv_key.size()], // Random pattern - valid ciphertext range + vec![0x21; priv_key.size()], // Another random pattern + vec![0x55; priv_key.size()], // Yet another pattern + { + // Sequential bytes starting at non-zero to avoid 0 mod N + let mut v = vec![0x01u8; priv_key.size()]; + for (i, b) in v.iter_mut().enumerate() { + *b = ((i + 1) & 0xFF) as u8; + } + v + }, + ]; + + for ciphertext in test_cases { + // None of these should return an error - that would be the Bleichenbacher oracle! + let result = decrypting_key.decrypt_implicit_rejection(&ciphertext, 48); + assert!( + result.is_ok(), + "Implicit rejection should never return padding errors" + ); + } + } + } } diff --git a/src/pkcs1v15/decrypting_key.rs b/src/pkcs1v15/decrypting_key.rs index d3ae9ee3..0e02e5f0 100644 --- a/src/pkcs1v15/decrypting_key.rs +++ b/src/pkcs1v15/decrypting_key.rs @@ -10,6 +10,9 @@ use rand_core::CryptoRng; use serde::{Deserialize, Serialize}; use zeroize::ZeroizeOnDrop; +#[cfg(feature = "implicit-rejection")] +use crate::traits::ImplicitRejectionDecryptor; + /// Decryption key for PKCS#1 v1.5 decryption as described in [RFC8017 § 7.2]. /// /// [RFC8017 § 7.2]: https://datatracker.ietf.org/doc/html/rfc8017#section-7.2 @@ -42,6 +45,26 @@ impl RandomizedDecryptor for DecryptingKey { } } +#[cfg(feature = "implicit-rejection")] +impl ImplicitRejectionDecryptor for DecryptingKey { + fn decrypt_implicit_rejection( + &self, + ciphertext: &[u8], + expected_len: usize, + ) -> Result> { + super::decrypt_implicit_rejection::(None, &self.inner, ciphertext, expected_len) + } + + fn decrypt_implicit_rejection_blinded( + &self, + rng: &mut R, + ciphertext: &[u8], + expected_len: usize, + ) -> Result> { + super::decrypt_implicit_rejection(Some(rng), &self.inner, ciphertext, expected_len) + } +} + impl EncryptingKeypair for DecryptingKey { type EncryptingKey = EncryptingKey; fn encrypting_key(&self) -> EncryptingKey { diff --git a/src/traits.rs b/src/traits.rs index d3563d4a..41155e7c 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -4,6 +4,8 @@ mod encryption; pub(crate) mod keys; mod padding; +#[cfg(feature = "implicit-rejection")] +pub use encryption::ImplicitRejectionDecryptor; pub use encryption::{Decryptor, EncryptingKeypair, RandomizedDecryptor, RandomizedEncryptor}; pub use keys::{PrivateKeyParts, PublicKeyParts}; pub use padding::{PaddingScheme, SignatureScheme}; diff --git a/src/traits/encryption.rs b/src/traits/encryption.rs index cb605419..e0db4f37 100644 --- a/src/traits/encryption.rs +++ b/src/traits/encryption.rs @@ -27,6 +27,64 @@ pub trait RandomizedDecryptor { ) -> Result>; } +/// Decrypt with implicit rejection to prevent Bleichenbacher/Marvin timing attacks. +/// +/// Instead of returning an error on invalid PKCS#1 v1.5 padding, this trait +/// returns a deterministic synthetic message derived from the ciphertext using +/// a PRF keyed by the private key. This prevents timing side-channels that could +/// leak information about padding validity. +/// +/// See [IETF draft-irtf-cfrg-rsa-guidance](https://datatracker.ietf.org/doc/draft-irtf-cfrg-rsa-guidance/) +/// for the specification of implicit rejection. +#[cfg(feature = "implicit-rejection")] +pub trait ImplicitRejectionDecryptor { + /// Decrypt the ciphertext with implicit rejection. + /// + /// This method **never fails** due to padding errors. On invalid padding, + /// it returns a deterministic synthetic plaintext derived from the ciphertext. + /// The caller cannot distinguish between valid and invalid ciphertexts + /// based on the return value or timing. + /// + /// Note: This method may still return errors for invalid keys, malformed + /// ciphertexts (e.g., wrong size), or RSA operation failures. Only padding + /// validation errors are suppressed via implicit rejection. + /// + /// # Arguments + /// * `ciphertext` - The RSA ciphertext to decrypt + /// * `expected_len` - The expected length of the plaintext (e.g., 48 for TLS premaster secret) + /// + /// # Returns + /// Either the actual plaintext (if padding was valid and length matches `expected_len`) + /// or a synthetic plaintext of `expected_len` bytes. + fn decrypt_implicit_rejection(&self, ciphertext: &[u8], expected_len: usize) + -> Result>; + + /// Decrypt the ciphertext with implicit rejection and RSA blinding. + /// + /// Same as [`decrypt_implicit_rejection`](Self::decrypt_implicit_rejection), but uses RSA blinding + /// with the provided RNG for additional side-channel protection against power analysis + /// and electromagnetic attacks on the modular exponentiation. + /// + /// Note: This method may still return errors for invalid keys, malformed + /// ciphertexts (e.g., wrong size), or RSA operation failures. Only padding + /// validation errors are suppressed via implicit rejection. + /// + /// # Arguments + /// * `rng` - Random number generator for blinding + /// * `ciphertext` - The RSA ciphertext to decrypt + /// * `expected_len` - The expected length of the plaintext (e.g., 48 for TLS premaster secret) + /// + /// # Returns + /// Either the actual plaintext (if padding was valid and length matches `expected_len`) + /// or a synthetic plaintext of `expected_len` bytes. + fn decrypt_implicit_rejection_blinded( + &self, + rng: &mut R, + ciphertext: &[u8], + expected_len: usize, + ) -> Result>; +} + /// Encryption keypair with an associated encryption key. pub trait EncryptingKeypair { /// Encrypting key type for this keypair.