diff --git a/pkcs12/CHANGELOG.md b/pkcs12/CHANGELOG.md index 3f1e58e25..cab7345ff 100644 --- a/pkcs12/CHANGELOG.md +++ b/pkcs12/CHANGELOG.md @@ -4,5 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added +- `EncryptedPrivateKeyInfo::decrypt_3des_cbc`: decrypt `pkcs8ShroudedKeyBag` entries + encrypted with `pbeWithSHAAnd3-KeyTripleDES-CBC` (OID 1.2.840.113549.1.12.1.3) +- SHA-1 KDF test vectors verified against OpenSSL 3.x `PKCS12KDF` provider +- Cross-vendor interoperability test against a pyca/cryptography-generated fixture +- Bouncy Castle cipher-layer isolation test vector + +### Changed +- `Pkcs12KeyType` now derives `Copy`, `Clone`, `Debug`, `PartialEq`, `Eq` +- `derive_key_utf8` returns `Err` for `rounds <= 0` (previously silent wrong output) +- Renamed `PKCS_12_PBEWITH_SHAAND40_BIT_RC2_CBC` → `PKCS_12_PBE_WITH_SHAAND40_BIT_RC2_CBC` + to match the naming pattern of all other PBE OID constants (breaking change) + ## 0.1.0 (2024-01-04) - Initial release diff --git a/pkcs12/Cargo.toml b/pkcs12/Cargo.toml index d2afabe8e..baddbae9d 100644 --- a/pkcs12/Cargo.toml +++ b/pkcs12/Cargo.toml @@ -25,17 +25,24 @@ cms = { version = "=0.3.0-pre.2", default-features = false } # optional dependencies digest = { version = "0.11", features = ["alloc"], optional = true } zeroize = { version = "1.8.1", optional = true, default-features = false } +sha1 = { version = "0.11.0", optional = true } +des = { version = "0.9.0-rc.3", optional = true } +cbc = { version = "0.2.0-rc.4", features = ["block-padding"], optional = true } [dev-dependencies] hex-literal = "1" -pkcs8 = { version = "0.11.0-rc.10", features = ["pkcs5"] } +pkcs8 = { version = "0.11.0-rc.10", features = ["alloc", "pkcs5"] } pkcs5 = { version = "0.8.0-rc.13", features = ["pbes2", "3des"] } +sha1 = "0.11.0" sha2 = "0.11" whirlpool = "0.11" [features] default = ["pem"] kdf = ["dep:digest", "zeroize/alloc"] +# Named "encryption" to match the convention used by pkcs8 in this monorepo, +# which uses a single "encryption" feature for both encrypt and decrypt paths. +encryption = ["kdf", "dep:sha1", "dep:des", "dep:cbc"] pem = ["der/pem", "x509-cert/pem"] [package.metadata.docs.rs] diff --git a/pkcs12/src/decrypt.rs b/pkcs12/src/decrypt.rs new file mode 100644 index 000000000..e565928c0 --- /dev/null +++ b/pkcs12/src/decrypt.rs @@ -0,0 +1,155 @@ +//! Decryption support for PKCS#12 shrouded key bags. +//! +//! Implements `pbeWithSHAAnd3-KeyTripleDES-CBC` (OID `1.2.840.113549.1.12.1.3`) +//! as defined in [RFC 7292 Appendix C]. +//! +//! ⚠️ **Security Warning**: this scheme is deprecated. New code should use PBES2 +//! with PBKDF2 and AES-256-CBC instead. This implementation exists solely to +//! support reading legacy PKCS#12 files. +//! +//! [RFC 7292 Appendix C]: https://www.rfc-editor.org/rfc/rfc7292#appendix-C + +use alloc::vec::Vec; +use cbc::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::Pkcs7}; +use zeroize::Zeroizing; + +use crate::{ + PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC, + kdf::{Pkcs12KeyType, derive_key_utf8}, + pbe_params::{EncryptedPrivateKeyInfo, Pkcs12PbeParams}, +}; + +/// 3-key Triple-DES key length: three 8-byte DES keys. +const DES3_KEY_LEN: usize = 24; + +/// DES block size in bytes; also the CBC IV length. +const DES3_BLOCK_SIZE: usize = 8; + +/// Maximum accepted KDF iteration count. +/// +/// RFC 7292 places no upper bound on the iteration count, but an +/// implementation-defined limit is required to prevent denial-of-service from +/// crafted .p12 files. At ~100 000 SHA-1 compressions per second, this cap +/// limits a single decrypt call to roughly 10 seconds on slow hardware while +/// still exceeding any iteration count used in practice. +const MAX_ITERATIONS: i32 = 1_000_000; + +type TdesEde3CbcDec = cbc::Decryptor; + +impl EncryptedPrivateKeyInfo { + /// Decrypt a `pkcs8ShroudedKeyBag` encrypted with + /// `pbeWithSHAAnd3-KeyTripleDES-CBC` (OID `1.2.840.113549.1.12.1.3`) + /// and return the plaintext PKCS#8 `PrivateKeyInfo` DER blob. + /// + /// `password` is the UTF-8 passphrase used when the file was created. + /// It must contain only characters in the Unicode Basic Multilingual Plane + /// (U+0000–U+FFFF); surrogate pairs and characters above U+FFFF are rejected. + /// + /// # Security + /// + /// This is a **low-level primitive**. It operates on a single + /// `EncryptedPrivateKeyInfo` bag and has no access to the `Pfx`-level + /// MAC (HMAC-SHA1 over the `AuthenticatedSafe`). + /// + /// **Callers that read from untrusted files MUST verify the PFX MAC before + /// calling this function.** PKCS#7 unpadding catches a wrong password + /// roughly 254 out of 255 times; the remaining ~0.4% of wrong-password + /// attempts produce garbage output that passes unpadding but will fail + /// downstream when the caller parses the result as PKCS#8. Without MAC + /// verification there is no protection against ciphertext tampering. + /// + /// # Errors + /// + /// Returns [`der::Error`] if: + /// - the `encryption_algorithm` OID is not `pbeWithSHAAnd3-KeyTripleDES-CBC`, + /// - algorithm parameters are absent or fail to decode as [`Pkcs12PbeParams`], + /// - `iterations` is not in the range `1..=MAX_ITERATIONS` (1 000 000), + /// - the salt is empty, + /// - `password` contains a character outside the Basic Multilingual Plane, + /// - the ciphertext length is zero or not a multiple of 8 bytes, or + /// - decryption or PKCS#7 unpadding fails (wrong password or corrupted data). + #[cfg_attr(docsrs, doc(cfg(feature = "encryption")))] + pub fn decrypt_3des_cbc(&self, password: &str) -> der::Result>> { + // Defensive: verify OID before touching any key material. + if self.encryption_algorithm.oid != PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC { + return Err(der::ErrorKind::OidUnknown { + oid: self.encryption_algorithm.oid, + } + .into()); + } + + // Decode the PBE parameters (salt + iteration count) from the + // AlgorithmIdentifier parameters field. `decode_as` avoids an + // intermediate DER serialisation: `der::Any` already holds the raw + // TLV bytes and can decode them directly into any `Choice + DecodeValue` + // type, which `Pkcs12PbeParams` satisfies via its `Sequence` derive. + let params_any = self + .encryption_algorithm + .parameters + .as_ref() + .ok_or(der::ErrorKind::Failed)?; + let params = params_any.decode_as::()?; + + // Defensive: iteration count must be strictly positive and below the + // implementation-defined denial-of-service limit (see MAX_ITERATIONS). + // The field is i32 in the ASN.1 schema; zero or negative is malformed. + if !(1..=MAX_ITERATIONS).contains(¶ms.iterations) { + return Err(der::ErrorKind::Failed.into()); + } + + let salt = params.salt.as_bytes(); + + // Defensive: a zero-length salt produces a trivially weak KDF input. + if salt.is_empty() { + return Err(der::ErrorKind::Failed.into()); + } + + // Derive the 3DES key (ID=1, 24 bytes) and CBC IV (ID=2, 8 bytes) + // using the RFC 7292 §B.2 KDF with SHA-1. + // Both are wrapped in Zeroizing so they are cleared on drop. + let key = Zeroizing::new(derive_key_utf8::( + password, + salt, + Pkcs12KeyType::EncryptionKey, + params.iterations, + DES3_KEY_LEN, + )?); + + let iv = Zeroizing::new(derive_key_utf8::( + password, + salt, + Pkcs12KeyType::Iv, + params.iterations, + DES3_BLOCK_SIZE, + )?); + + let ciphertext = self.encrypted_data.as_bytes(); + + // Defensive: ciphertext must be non-empty and a multiple of the + // 3DES block size (8 bytes). An empty or misaligned buffer cannot + // be a valid PKCS#7-padded ciphertext. + if ciphertext.is_empty() || ciphertext.len() % DES3_BLOCK_SIZE != 0 { + return Err(der::ErrorKind::Failed.into()); + } + + // Build the CBC decryptor. new_from_slices validates key (24 bytes) + // and IV (8 bytes) lengths; any mismatch is a programming error but + // we propagate it as a DER failure rather than panicking. + let decryptor = TdesEde3CbcDec::new_from_slices(&key, &iv) + .map_err(|_| der::Error::from(der::ErrorKind::Failed))?; + + // Decrypt in-place into a Zeroizing buffer so key material in the + // plaintext is cleared if the caller drops the result. + let mut buf = Zeroizing::new(ciphertext.to_vec()); + let plaintext = decryptor + .decrypt_padded::(&mut buf) + .map_err(|_| der::Error::from(der::ErrorKind::Failed))?; + + // Defensive: a valid PKCS#8 PrivateKeyInfo cannot be empty. + if plaintext.is_empty() { + return Err(der::ErrorKind::Failed.into()); + } + + Ok(Zeroizing::new(plaintext.to_vec())) + } +} diff --git a/pkcs12/src/kdf.rs b/pkcs12/src/kdf.rs index 3c1102707..b60f6b503 100644 --- a/pkcs12/src/kdf.rs +++ b/pkcs12/src/kdf.rs @@ -22,6 +22,7 @@ use zeroize::{Zeroize, Zeroizing}; /// Specify the usage type of the generated key /// This allows to derive distinct encryption keys, IVs and MAC from the same password or text /// string. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Pkcs12KeyType { /// Use key for encryption EncryptionKey = 1, @@ -48,11 +49,24 @@ pub fn derive_key_utf8( where D: Digest + FixedOutputReset + BlockSizeUser, { + // Validate before converting password: a non-positive round count is + // malformed (RFC 7292 §B.2 requires at least one iteration). Without + // this guard, rounds <= 0 would silently produce single-hash output. + if rounds < 1 { + return Err(der::ErrorKind::Failed.into()); + } let password_bmp = BmpString::from_utf8(password)?; Ok(derive_key_bmp::(password_bmp, salt, id, rounds, key_len)) } -/// Derive +/// Derives `key` of type `id` from a BMP-encoded `password` and `salt` with +/// length `key_len` using `rounds` iterations of the RFC 7292 §B.2 algorithm. +/// +/// Use this function when the password is already available as a [`BmpString`] +/// (e.g. parsed directly from a DER `BMPString` field). It appends the +/// mandatory two-byte null terminator required by the PKCS#12 spec so the +/// caller does not have to. Use [`derive_key_utf8`] when the password is a +/// plain UTF-8 `&str`. pub fn derive_key_bmp( password: BmpString, salt: &[u8], @@ -65,18 +79,29 @@ where { let mut password = Zeroizing::new(Vec::from(password.into_bytes())); - // Password is NULL terminated + // RFC 7292 §B.1: append two-byte UTF-16BE null terminator. password.extend([0u8; 2]); derive_key::(&password, salt, id, rounds, key_len) } /// Derives `key` of type `id` from `pass` and `salt` with length `key_len` using `rounds` -/// iterations of the algorithm -/// `pass` must be a unicode (utf16) byte array in big endian order without order mark and with two -/// terminating zero bytes. +/// iterations of the algorithm. +/// +/// `pass` must be a UTF-16 Big Endian byte array without a byte-order mark and with two +/// terminating zero bytes (the mandatory PKCS#12 null terminator). +/// +/// `rounds` must be `>= 1`. A value of `0` or negative is not meaningful per RFC 7292 §B.2; +/// prefer [`derive_key_utf8`] or [`derive_key_bmp`], which validate this for you. +/// /// ```rust -/// let key = pkcs12::kdf::derive_key_utf8::("top-secret", &[0x1, 0x2, 0x3, 0x4], +/// // "top-secret" as UTF-16BE with null terminator +/// let pass_bmp: Vec = "top-secret".encode_utf16() +/// .flat_map(|c| c.to_be_bytes()) +/// .chain([0u8, 0u8]) +/// .collect(); +/// let key = pkcs12::kdf::derive_key::( +/// &pass_bmp, &[0x1, 0x2, 0x3, 0x4], /// pkcs12::kdf::Pkcs12KeyType::EncryptionKey, 1000, 32); /// ``` pub fn derive_key( @@ -89,6 +114,11 @@ pub fn derive_key( where D: Digest + FixedOutputReset + BlockSizeUser, { + // rounds must be >= 1: the inner loop is `for _ in 1..rounds` which runs + // zero times for rounds <= 0, producing single-hash output regardless of + // the requested count. Validated by derive_key_utf8; asserted here as a + // defence-in-depth check for direct callers. + debug_assert!(rounds >= 1, "rounds must be positive (got {rounds})"); let mut digest = D::new(); let output_size = ::output_size(); let block_size = D::block_size(); @@ -146,7 +176,7 @@ where result = digest.finalize_fixed_reset(); } - // 7. Concateate A_1, A_2, ..., A_c together to form a pseudorandom + // 7. Concatenate A_1, A_2, ..., A_c together to form a pseudorandom // bit string, A. // [ Instead of storing all Ais and concatenating later, we concatenate // them immediately ] @@ -160,7 +190,7 @@ where // 6. B. Concatenate copies of Ai to create a string B of length v // bits (the final copy of Ai may be truncated to create B). - // [ we achieve this on thy fly with the expression `result[k % output_size]` below] + // [ we achieve this on the fly with the expression `result[k % output_size]` below] // 6. C. Treating I as a concatenation I_0, I_1, ..., I_(k-1) of v-bit // blocks, where k=ceiling(s/v)+ceiling(p/v), modify I by diff --git a/pkcs12/src/lib.rs b/pkcs12/src/lib.rs index ac8a29436..c9ef55b80 100644 --- a/pkcs12/src/lib.rs +++ b/pkcs12/src/lib.rs @@ -23,6 +23,9 @@ pub mod safe_bag; #[cfg(feature = "kdf")] pub mod kdf; +#[cfg(feature = "encryption")] +mod decrypt; + mod authenticated_safe; mod bag_type; mod cert_type; @@ -49,24 +52,24 @@ use const_oid::ObjectIdentifier; pub const PKCS_12_PBE_WITH_SHAAND128_BIT_RC4: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.1"); -/// `pbeWithSHAAnd128BitRC4` Object Identifier (OID). +/// `pbeWithSHAAnd40BitRC4` Object Identifier (OID). pub const PKCS_12_PBE_WITH_SHAAND40_BIT_RC4: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.2"); -/// `pbeWithSHAAnd128BitRC4` Object Identifier (OID). +/// `pbeWithSHAAnd3-KeyTripleDES-CBC` Object Identifier (OID). pub const PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.3"); -/// `pbeWithSHAAnd128BitRC4` Object Identifier (OID). +/// `pbeWithSHAAnd2-KeyTripleDES-CBC` Object Identifier (OID). pub const PKCS_12_PBE_WITH_SHAAND2_KEY_TRIPLE_DES_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.4"); -/// `pbeWithSHAAnd128BitRC4` Object Identifier (OID). +/// `pbeWithSHAAnd128BitRC2-CBC` Object Identifier (OID). pub const PKCS_12_PBE_WITH_SHAAND128_BIT_RC2_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.5"); -/// `pbeWithSHAAnd128BitRC4` Object Identifier (OID). -pub const PKCS_12_PBEWITH_SHAAND40_BIT_RC2_CBC: ObjectIdentifier = +/// `pbeWithSHAAnd40BitRC2-CBC` Object Identifier (OID). +pub const PKCS_12_PBE_WITH_SHAAND40_BIT_RC2_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.12.1.6"); // bag types @@ -102,10 +105,3 @@ pub const PKCS_12_X509_CERT_OID: ObjectIdentifier = /// `pkcs-9 sdsiCertificate for pkcs-12` Object Identifier (OID). pub const PKCS_12_SDSI_CERT_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.22.2"); - -// todo: return the friendly name if present? (minimally, defer until BMPString support is available) -// todo: support separate mac and encryption passwords? -// todo: add decryption support -// todo: add more encryption tests -// todo: add a builder -// todo: add RC2 support diff --git a/pkcs12/src/pbe_params.rs b/pkcs12/src/pbe_params.rs index 6f0b04ecd..1f203a182 100644 --- a/pkcs12/src/pbe_params.rs +++ b/pkcs12/src/pbe_params.rs @@ -15,7 +15,7 @@ use spki::AlgorithmIdentifierOwned; /// [RFC 7292 Appendix C]: https://www.rfc-editor.org/rfc/rfc7292#appendix-C #[derive(Clone, Debug, Eq, PartialEq, Sequence, ValueOrd)] pub struct Pkcs12PbeParams { - /// the MAC digest info + /// the PBE salt pub salt: OctetString, /// the number of iterations @@ -54,24 +54,39 @@ pub struct Pbkdf2Params { pub prf: AlgorithmIdentifierOwned, } +/// Payload of a `pkcs8ShroudedKeyBag` as defined in [RFC 7292 §4.2.2] and +/// [RFC 5958 §2]. +/// +/// ```text /// EncryptedPrivateKeyInfo ::= SEQUENCE { /// encryptionAlgorithm EncryptionAlgorithmIdentifier, /// encryptedData EncryptedData } +/// ``` +/// +/// [RFC 7292 §4.2.2]: https://www.rfc-editor.org/rfc/rfc7292#section-4.2.2 +/// [RFC 5958 §2]: https://www.rfc-editor.org/rfc/rfc5958#section-2 #[derive(Clone, Debug, Eq, PartialEq, Sequence)] -#[allow(missing_docs)] pub struct EncryptedPrivateKeyInfo { + /// Algorithm used to encrypt the private key (OID + parameters). pub encryption_algorithm: AlgorithmIdentifierOwned, + /// Ciphertext produced by applying `encryption_algorithm` to the + /// DER-encoded `PrivateKeyInfo`. pub encrypted_data: OctetString, } -///```text +/// PBES2 scheme parameters as defined in [RFC 8018 §A.4]. +/// +/// ```text /// PBES2-params ::= SEQUENCE { -/// keyDerivationFunc AlgorithmIdentifier {{PBES2-KDFs}}, -/// encryptionScheme AlgorithmIdentifier {{PBES2-Encs}} } -///``` +/// keyDerivationFunc AlgorithmIdentifier {{PBES2-KDFs}}, +/// encryptionScheme AlgorithmIdentifier {{PBES2-Encs}} } +/// ``` +/// +/// [RFC 8018 §A.4]: https://www.rfc-editor.org/rfc/rfc8018#appendix-A.4 #[derive(Clone, Debug, Eq, PartialEq, Sequence)] -#[allow(missing_docs)] pub struct Pbes2Params { + /// Key derivation function (e.g. PBKDF2). pub kdf: AlgorithmIdentifierOwned, + /// Symmetric encryption scheme (e.g. AES-256-CBC). pub encryption: AlgorithmIdentifierOwned, } diff --git a/pkcs12/src/safe_bag.rs b/pkcs12/src/safe_bag.rs index eb4ffb61f..80a591134 100644 --- a/pkcs12/src/safe_bag.rs +++ b/pkcs12/src/safe_bag.rs @@ -2,9 +2,7 @@ use alloc::vec::Vec; use const_oid::ObjectIdentifier; -use der::asn1::OctetString; -use der::{AnyRef, Decode, Enumerated, Sequence}; -use spki::AlgorithmIdentifierOwned; +use der::{AnyRef, Decode}; use x509_cert::attr::Attributes; /// The `SafeContents` type is defined in [RFC 7292 Section 4.2]. @@ -31,7 +29,6 @@ pub type SafeContents = Vec; #[allow(missing_docs)] pub struct SafeBag { pub bag_id: ObjectIdentifier, - //#[asn1(context_specific = "0", tag_mode = "EXPLICIT")] pub bag_value: Vec, pub bag_attributes: Option, } @@ -44,10 +41,7 @@ impl<'a> ::der::DecodeValue<'a> for SafeBag { _header: ::der::Header, ) -> ::der::Result { let bag_id = reader.decode()?; - let bag_value = match reader.tlv_bytes() { - Ok(v) => v.to_vec(), - Err(e) => return Err(e), - }; + let bag_value = reader.tlv_bytes()?.to_vec(); let bag_attributes = reader.decode()?; Ok(Self { bag_id, @@ -88,42 +82,3 @@ impl ::der::EncodeValue for SafeBag { } } impl ::der::Sequence<'_> for SafeBag {} - -/// Version for the PrivateKeyInfo structure as defined in [RFC 5208 Section 5]. -/// -/// [RFC 5208 Section 5]: https://www.rfc-editor.org/rfc/rfc5208#section-5 -#[derive(Clone, Copy, Debug, Enumerated, Eq, PartialEq, PartialOrd, Ord)] -#[asn1(type = "INTEGER")] -#[repr(u8)] -pub enum Pkcs8Version { - /// syntax version 3 - V0 = 0, -} - -// PrivateKeyInfo is defined in the pkcs8 crate but without Debug, PartialEq, Eq, Sequence -/// The `PrivateKeyInfo` type is defined in [RFC 5208 Section 5]. -/// -/// ```text -/// PrivateKeyInfo ::= SEQUENCE { -/// version Version, -/// privateKeyAlgorithm PrivateKeyAlgorithmIdentifier, -/// privateKey PrivateKey, -/// attributes [0] IMPLICIT Attributes OPTIONAL } -/// ``` -/// -/// [RFC 5208 Section 5]: https://www.rfc-editor.org/rfc/rfc5208#section-5 -#[derive(Clone, Debug, PartialEq, Eq, Sequence)] -pub struct PrivateKeyInfo { - /// Syntax version number (always 0 for RFC 5208) - pub version: Pkcs8Version, - - /// X.509 `AlgorithmIdentifier` for the private key type. - pub algorithm: AlgorithmIdentifierOwned, - - /// Private key data. - pub private_key: OctetString, - - /// Public key data, optionally available if version is V2. - #[asn1(context_specific = "0", tag_mode = "IMPLICIT", optional = "true")] - pub attributes: Option, -} diff --git a/pkcs12/tests/data/README.md b/pkcs12/tests/data/README.md new file mode 100644 index 000000000..3cd1140d6 --- /dev/null +++ b/pkcs12/tests/data/README.md @@ -0,0 +1,32 @@ +# Test Fixture Files + +## Generated fixtures (password: `hunter2`) + +All three files contain the same RSA-2048 key and self-signed certificate, encrypted +with `pbeWithSHA1And3-KeyTripleDES-CBC`. They differ only in iteration count. + +Public key fingerprint (SHA-256 of DER pubkey, oracle for decryption tests): +`adbe3a3bb8f734eed65bb2c841ebce69c3d0fac37722c8d794de5b37399411fd` + +Generated with: +```bash +openssl genrsa -out key.pem 2048 +openssl req -x509 -new -key key.pem -out cert.pem -days 3650 -nodes -subj "/CN=pkcs12-test/O=test/C=US" +openssl pkcs12 -export -legacy -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \ + -inkey key.pem -in cert.pem -out test-3des-iter.p12 -passout pass:hunter2 [-iter ] +``` + +| File | Iterations | Cipher | +|------|-----------|--------| +| `test-3des-iter1.p12` | 1 | pbeWithSHA1And3-KeyTripleDES-CBC | +| `test-3des-iter2048.p12` | 2048 | pbeWithSHA1And3-KeyTripleDES-CBC | +| `test-3des-iter100000.p12` | 100000 | pbeWithSHA1And3-KeyTripleDES-CBC | + +## pyca/cryptography fixture (password: `cryptography`) + +`pyca-cert-rc2-key-3des.p12` — from the pyca/cryptography test vectors. +- Certificate: pbeWithSHA1And40BitRC2-CBC (not tested here) +- Private key: EC (`id-ecPublicKey`), pbeWithSHA1And3-KeyTripleDES-CBC, 2048 iterations + +Public key fingerprint (SHA-256 of DER pubkey): +`c5eacb73dd8324007d050afcc807fccd09c1f752634eeafaffc0872b35da4383` diff --git a/pkcs12/tests/data/pyca-cert-rc2-key-3des.p12 b/pkcs12/tests/data/pyca-cert-rc2-key-3des.p12 new file mode 100644 index 000000000..9041671be Binary files /dev/null and b/pkcs12/tests/data/pyca-cert-rc2-key-3des.p12 differ diff --git a/pkcs12/tests/data/test-3des-iter1.p12 b/pkcs12/tests/data/test-3des-iter1.p12 new file mode 100644 index 000000000..6b3df06be Binary files /dev/null and b/pkcs12/tests/data/test-3des-iter1.p12 differ diff --git a/pkcs12/tests/data/test-3des-iter100000.p12 b/pkcs12/tests/data/test-3des-iter100000.p12 new file mode 100644 index 000000000..36799d5ed Binary files /dev/null and b/pkcs12/tests/data/test-3des-iter100000.p12 differ diff --git a/pkcs12/tests/data/test-3des-iter2048.p12 b/pkcs12/tests/data/test-3des-iter2048.p12 new file mode 100644 index 000000000..47ad409e8 Binary files /dev/null and b/pkcs12/tests/data/test-3des-iter2048.p12 differ diff --git a/pkcs12/tests/decrypt_3des.rs b/pkcs12/tests/decrypt_3des.rs new file mode 100644 index 000000000..e57268c7a --- /dev/null +++ b/pkcs12/tests/decrypt_3des.rs @@ -0,0 +1,292 @@ +//! Integration tests for `pbeWithSHAAnd3-KeyTripleDES-CBC` decryption +//! (OID 1.2.840.113549.1.12.1.3, RFC 7292 §B.2 + Appendix C). +//! +//! Test fixtures are in `tests/data/`; see `tests/data/README.md` for +//! generation commands and oracle fingerprints. +#![cfg(feature = "encryption")] + +use der::{ + Decode, Encode, + asn1::{Any, ContextSpecific, OctetString}, +}; +use hex_literal::hex; +use pkcs12::{ + AuthenticatedSafe, PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC, PKCS_12_PKCS8_KEY_BAG_OID, + pbe_params::{EncryptedPrivateKeyInfo, Pkcs12PbeParams}, + pfx::Pfx, + safe_bag::SafeContents, +}; +// Use the canonical pkcs8 type rather than a local duplicate. +// pkcs8::PrivateKeyInfoOwned implements PartialEq/Eq via constant-time +// comparison (subtle feature) on the key material — safer than a naive derive. +use pkcs8::PrivateKeyInfoOwned; +use sha2::{Digest, Sha256}; +use spki::AlgorithmIdentifierOwned; + +/// SHA-256 of the PrivateKeyInfo DER blob inside all three `hunter2` RSA +/// fixtures (iter=1, iter=2048, iter=100000 — all encrypt the same key). +/// +/// Oracle source: independently verified with: +/// - pyca/cryptography `pkcs12.load_key_and_certificates` + `private_bytes(DER, PKCS8, NoEncryption)` +/// - `openssl pkcs12 -legacy ... -nodes | openssl pkcs8 -nocrypt -topk8 -outform DER | sha256sum` +/// +/// Both external tools agree on this value. Any change to this constant +/// indicates a regression in the KDF or decryption path. +const RSA_KEY_DER_SHA256: [u8; 32] = + hex!("ccdf40f8d0881c5aa3cb9c563399f5fb590f7615ef7da4d057031bc809c9190d"); + +/// SHA-256 of the PrivateKeyInfo DER blob inside the pyca EC fixture. +/// +/// Oracle source: pyca/cryptography (independent EC implementation, password +/// `"cryptography"`, 2048 iterations, `pbeWithSHA1And3-KeyTripleDES-CBC`). +const PYCA_EC_KEY_DER_SHA256: [u8; 32] = + hex!("956890dd43249260db8b4a7edf87541070086c186f6a5e39e2eba2eec28f634c"); + +// OIDs used in assertions. +const ID_DATA: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.1"); +const RSA_ENCRYPTION: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); +const EC_PUBLIC_KEY: const_oid::ObjectIdentifier = + const_oid::ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Walk a PFX and return the first `pkcs8ShroudedKeyBag` found inside any +/// plaintext (`data`) `ContentInfo`. Panics if none is found. +fn find_shrouded_key(pfx_bytes: &[u8]) -> EncryptedPrivateKeyInfo { + let pfx = Pfx::from_der(pfx_bytes).expect("parse Pfx"); + let pfx_content_der = pfx.auth_safe.content.to_der().unwrap(); + let auth_safes_os = OctetString::from_der(&pfx_content_der).unwrap(); + let auth_safes: AuthenticatedSafe<'_> = + AuthenticatedSafe::from_der(auth_safes_os.as_bytes()).unwrap(); + + for ci in auth_safes { + if ci.content_type != ID_DATA { + continue; + } + let ci_der = ci.content.to_der().unwrap(); + let safe_os = OctetString::from_der(&ci_der).unwrap(); + let bags = SafeContents::from_der(safe_os.as_bytes()).unwrap(); + for bag in bags { + if bag.bag_id != PKCS_12_PKCS8_KEY_BAG_OID { + continue; + } + // bag_value is the raw TLV of [0] EXPLICIT EncryptedPrivateKeyInfo. + let cs: ContextSpecific = + ContextSpecific::from_der(&bag.bag_value).unwrap(); + return cs.value; + } + } + panic!("no pkcs8ShroudedKeyBag found in PFX"); +} + +/// Decrypt the first `pkcs8ShroudedKeyBag` in `pfx_bytes` and return the +/// raw PKCS#8 `PrivateKeyInfo` DER. Panics on any parsing or decryption error. +fn decrypt_key(pfx_bytes: &[u8], password: &str) -> Vec { + let epki = find_shrouded_key(pfx_bytes); + epki.decrypt_3des_cbc(password) + .expect("decrypt_3des_cbc failed") + .to_vec() +} + +// ── happy-path tests ────────────────────────────────────────────────────────── + +/// Decrypt with 1 KDF iteration. Smoke-tests the low-iteration path. +#[test] +fn decrypt_3des_hunter2_iter1() { + let pki_der = decrypt_key(include_bytes!("data/test-3des-iter1.p12"), "hunter2"); + let pki = PrivateKeyInfoOwned::from_der(&pki_der).expect("parse PrivateKeyInfo"); + assert_eq!(pki.algorithm.oid, RSA_ENCRYPTION); + assert!(!pki.private_key.as_bytes().is_empty()); +} + +/// Decrypt with 2048 KDF iterations (standard OpenSSL default). +#[test] +fn decrypt_3des_hunter2_iter2048() { + let pki_der = decrypt_key(include_bytes!("data/test-3des-iter2048.p12"), "hunter2"); + let pki = PrivateKeyInfoOwned::from_der(&pki_der).expect("parse PrivateKeyInfo"); + assert_eq!(pki.algorithm.oid, RSA_ENCRYPTION); + assert!(!pki.private_key.as_bytes().is_empty()); +} + +/// Decrypt with 100 000 KDF iterations. +#[test] +fn decrypt_3des_hunter2_iter100000() { + let pki_der = decrypt_key(include_bytes!("data/test-3des-iter100000.p12"), "hunter2"); + let pki = PrivateKeyInfoOwned::from_der(&pki_der).expect("parse PrivateKeyInfo"); + assert_eq!(pki.algorithm.oid, RSA_ENCRYPTION); + assert!(!pki.private_key.as_bytes().is_empty()); +} + +/// **Key consistency oracle**: all three files contain the same RSA-2048 key +/// encrypted with different iteration counts. If our KDF or decryption is +/// wrong, at least two of the three outputs will differ. +#[test] +fn decrypt_3des_all_iter_variants_agree() { + let k1 = decrypt_key(include_bytes!("data/test-3des-iter1.p12"), "hunter2"); + let k2048 = decrypt_key(include_bytes!("data/test-3des-iter2048.p12"), "hunter2"); + let k100k = decrypt_key(include_bytes!("data/test-3des-iter100000.p12"), "hunter2"); + assert_eq!( + k1, k2048, + "iter=1 and iter=2048 must decrypt to the same key" + ); + assert_eq!( + k1, k100k, + "iter=1 and iter=100000 must decrypt to the same key" + ); +} + +// ── error-path tests ────────────────────────────────────────────────────────── + +/// Wrong password must produce `Err` (PKCS#7 unpadding detects the bad key). +#[test] +fn decrypt_3des_wrong_password_fails() { + let epki = find_shrouded_key(include_bytes!("data/test-3des-iter2048.p12")); + assert!( + epki.decrypt_3des_cbc("wrong-password").is_err(), + "decryption with wrong password must return Err" + ); +} + +// ── cross-vendor interoperability ───────────────────────────────────────────── + +/// pyca/cryptography test vector: private key encrypted with +/// `pbeWithSHA1And3-KeyTripleDES-CBC`, 2048 iterations, password `cryptography`. +/// Certificate uses RC2-CBC (not tested here). The private key is an EC key +/// (`id-ecPublicKey`, OID 1.2.840.10045.2.1). The fixture is from an +/// independent implementation; successful decryption proves interoperability. +#[test] +fn decrypt_3des_pyca_fixture() { + let pki_der = decrypt_key( + include_bytes!("data/pyca-cert-rc2-key-3des.p12"), + "cryptography", + ); + let pki = PrivateKeyInfoOwned::from_der(&pki_der).expect("parse PrivateKeyInfo"); + assert_eq!(pki.algorithm.oid, EC_PUBLIC_KEY); + assert!(!pki.private_key.as_bytes().is_empty()); +} + +// ── external oracle fingerprint checks ─────────────────────────────────────── + +/// Verify that the decrypted `PrivateKeyInfo` DER bytes match the SHA-256 +/// fingerprint computed by two independent external implementations +/// (pyca/cryptography and OpenSSL). This is the strongest oracle available +/// without re-implementing RSA key parsing: a regression in the KDF or +/// cipher layer will change these bytes and fail this test. +/// +/// Expected values documented in `tests/data/README.md` and hardcoded in +/// `RSA_KEY_DER_SHA256` and `PYCA_EC_KEY_DER_SHA256` at the top of this file. +#[test] +fn decrypt_3des_fingerprint_oracle() { + // All three RSA fixtures contain the same key at different iteration counts. + // Each decrypted DER blob must hash to RSA_KEY_DER_SHA256. + for (path, label) in [ + ( + include_bytes!("data/test-3des-iter1.p12").as_slice(), + "iter=1", + ), + ( + include_bytes!("data/test-3des-iter2048.p12").as_slice(), + "iter=2048", + ), + ( + include_bytes!("data/test-3des-iter100000.p12").as_slice(), + "iter=100000", + ), + ] { + let pki_der = decrypt_key(path, "hunter2"); + let hash = Sha256::digest(&pki_der); + assert_eq!( + hash.as_slice(), + RSA_KEY_DER_SHA256, + "{label}: PrivateKeyInfo DER sha256 does not match oracle" + ); + } + + // pyca EC fixture: independent implementation, different algorithm. + let pyca_der = decrypt_key( + include_bytes!("data/pyca-cert-rc2-key-3des.p12"), + "cryptography", + ); + let pyca_hash = Sha256::digest(&pyca_der); + assert_eq!( + pyca_hash.as_slice(), + PYCA_EC_KEY_DER_SHA256, + "pyca fixture: PrivateKeyInfo DER sha256 does not match oracle" + ); +} + +// ── cipher-layer isolation test ─────────────────────────────────────────────── + +/// Bouncy Castle PBETest.java cipher-layer isolation vector. +/// +/// This test bypasses PFX parsing and exercises only the KDF + 3DES-CBC +/// decrypt path. All values are derived from two independent external +/// oracles; the test constructs a minimal `EncryptedPrivateKeyInfo` in +/// memory rather than reading a `.p12` file. +/// +/// ## Oracle sources +/// +/// **KDF** (key and IV) — OpenSSL 3.x `PKCS12KDF` provider: +/// ```text +/// PASSWORD_HEX=007000610073007300770... (BMP UTF-16BE "password" + 0000) +/// openssl kdf -provider legacy -provider default -digest SHA1 \ +/// -kdfopt hexpass:$PASSWORD_HEX \ +/// -kdfopt hexsalt:7d60435f02e9e0ae \ +/// -kdfopt iter:2048 -kdfopt id:1 -keylen 24 PKCS12KDF +/// → 732f2d33c801732b7206756cbd44f9c1c103ddd97c7cbe8e (key) +/// openssl kdf ... -kdfopt id:2 -keylen 8 PKCS12KDF +/// → b07bf522c8d608b8 (IV) +/// ``` +/// +/// **Ciphertext** — OpenSSL `enc -des-ede3-cbc` applied to the PKCS#7-padded +/// known plaintext with the KDF-derived key and IV: +/// ```text +/// echo -n "1234567890abcdef..." | xxd -r -p \ +/// | openssl enc -des-ede3-cbc -K -iv -nosalt +/// → 9594495aa2cfc9a5bb210823454146a39cc584dab504ae1a +/// ``` +/// Round-trip verified: decrypting the ciphertext with OpenSSL returns the +/// original plaintext byte-for-byte. +/// +/// **Test parameters** from Bouncy Castle `PBETest.java` +/// (`bcgit/bc-java`, `prov/src/test/java/org/bouncycastle/jce/provider/test/`): +/// - password: `"password"`, salt: `7d60435f02e9e0ae`, iterations: 2048 +/// - algorithm: `PBEWithSHAAnd3-KeyTripleDES-CBC` +#[test] +fn decrypt_3des_bouncy_castle_cipher_layer() { + // Build a minimal EncryptedPrivateKeyInfo in memory — no .p12 file involved. + let salt = OctetString::new(hex!("7d60435f02e9e0ae").to_vec()).expect("salt OctetString"); + let pbe_params = Pkcs12PbeParams { + salt, + iterations: 2048, + }; + // Encode Pkcs12PbeParams to DER, then wrap as der::Any for the + // AlgorithmIdentifier parameters field. + let params_der = pbe_params.to_der().expect("encode Pkcs12PbeParams"); + let params_any = Any::from_der(¶ms_der).expect("Any from Pkcs12PbeParams DER"); + + let ciphertext = + OctetString::new(hex!("9594495aa2cfc9a5bb210823454146a39cc584dab504ae1a").to_vec()) + .expect("ciphertext OctetString"); + + let epki = EncryptedPrivateKeyInfo { + encryption_algorithm: AlgorithmIdentifierOwned { + oid: PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC, + parameters: Some(params_any), + }, + encrypted_data: ciphertext, + }; + + let plaintext = epki + .decrypt_3des_cbc("password") + .expect("decrypt_3des_cbc failed on Bouncy Castle vector"); + + // Known plaintext from Bouncy Castle PBETest.java (23 bytes). + assert_eq!( + &*plaintext, + &hex!("1234567890abcdefabcdef1234567890fedbca098765"), + "Bouncy Castle cipher-layer vector: decrypted plaintext does not match" + ); +} diff --git a/pkcs12/tests/kdf.rs b/pkcs12/tests/kdf.rs index 6c5004125..19c0d906b 100644 --- a/pkcs12/tests/kdf.rs +++ b/pkcs12/tests/kdf.rs @@ -169,6 +169,63 @@ fn pkcs12_key_derive_whirlpool() { ); } +/// SHA-1 KDF vectors for pbeWithSHAAnd3-KeyTripleDES-CBC (OID 1.2.840.113549.1.12.1.3). +/// All expected values generated with OpenSSL 3.0 using hexpass (BMP-encoded password): +/// +/// PASSWORD_HEX=$(python3 -c \ +/// "s='password'; print(''.join(f'{ord(c):04x}' for c in s) + '0000')") +/// openssl kdf -provider legacy -provider default -digest SHA1 \ +/// -kdfopt hexpass:$PASSWORD_HEX -kdfopt hexsalt: \ +/// -kdfopt iter: -kdfopt id: -keylen PKCS12KDF +/// +/// IMPORTANT: use `hexpass:` (BMP/UTF-16BE + null terminator), NOT `pass:`. +/// The `pass:` flag passes raw ASCII bytes, which diverges from the PKCS#12 +/// spec (and from `derive_key_utf8`) for all passwords, including pure ASCII. +/// Cross-validated: SHA-256 vectors match upstream kdf.rs test expectations. +#[test] +fn pkcs12_key_derive_sha1_password_2048() { + // password="password", salt=00 01 02 03 04 05 06 07, iter=2048 + const SALT: [u8; 8] = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]; + + // ID=1 (EncryptionKey), 24 bytes — 3DES key length + assert_eq!( + derive_key_utf8::("password", &SALT, Pkcs12KeyType::EncryptionKey, 2048, 24) + .unwrap(), + hex!("d0fced80aa6413a0b14c5c21d5869a78e3bbf36d4fd2a7fa") + ); + + // ID=2 (IV), 8 bytes — 3DES IV length + assert_eq!( + derive_key_utf8::("password", &SALT, Pkcs12KeyType::Iv, 2048, 8).unwrap(), + hex!("ea35854d10fc84f3") + ); + + // ID=3 (MAC), 20 bytes — SHA-1 HMAC key length + assert_eq!( + derive_key_utf8::("password", &SALT, Pkcs12KeyType::Mac, 2048, 20).unwrap(), + hex!("01a2ae2f9281dea66b1f07f68a0c030d170d7d9b") + ); +} + +#[test] +fn pkcs12_key_derive_sha1_hunter2_1024() { + // password="hunter2", salt=0xdeadbeefcafebabe, iter=1024 + const SALT: [u8; 8] = [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]; + + // ID=1 (EncryptionKey), 24 bytes + assert_eq!( + derive_key_utf8::("hunter2", &SALT, Pkcs12KeyType::EncryptionKey, 1024, 24) + .unwrap(), + hex!("443402ec5909c0b28f3b919115f945afd5fce925713f8c73") + ); + + // ID=2 (IV), 8 bytes + assert_eq!( + derive_key_utf8::("hunter2", &SALT, Pkcs12KeyType::Iv, 1024, 8).unwrap(), + hex!("28830f9a1d8052c2") + ); +} + #[test] fn pkcs12_key_derive_special_chars() { const PASS_SHORT: &str = "🔥"; @@ -185,3 +242,22 @@ fn pkcs12_key_derive_special_chars() { .is_err() ); // Emoji is not in the Basic Multilingual Plane } + +/// A non-positive round count is malformed (RFC 7292 §B.2 requires at least +/// one iteration). `derive_key_utf8` must return `Err` for `rounds <= 0` +/// rather than silently producing single-hash output. +#[test] +fn pkcs12_key_derive_nonpositive_rounds_fails() { + const SALT: [u8; 8] = [0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]; + + assert!( + derive_key_utf8::("password", &SALT, Pkcs12KeyType::EncryptionKey, 0, 32) + .is_err(), + "rounds=0 must return Err" + ); + assert!( + derive_key_utf8::("password", &SALT, Pkcs12KeyType::EncryptionKey, -1, 32) + .is_err(), + "rounds=-1 must return Err" + ); +}