From 06fa2780fb16957c337d16dcc09a3a11fc4e5e09 Mon Sep 17 00:00:00 2001 From: Mark Atwood Date: Fri, 10 Apr 2026 20:19:42 -0700 Subject: [PATCH] feat(pkcs12/kdf): zeroize KDF output and intermediates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return `Zeroizing>` from `derive_key`, `derive_key_bmp`, and `derive_key_utf8` so all key material is erased from memory on drop. The returned key being wrapped in `Zeroizing` is documented on each public function. Three buffers are now zeroized: - `out` (the derived key) — wrapped in `Zeroizing`, erased when the caller drops the return value. - `result` (per-round intermediate hash) — wrapped in `Zeroizing`; each reassignment in the inner loop drops and erases the previous value before the next hash is computed. - `init_key` (the S||P buffer holding stretched password bytes) — wrapped in `Zeroizing` for panic-safe erasure on unwind, plus an explicit `.zeroize()` call on the normal return path for eager erasure. Enable `digest/zeroize` (zeroes the digest's internal block buffer, which holds input fragments of id||salt||password between Update calls) and add `hybrid-array` as a direct optional dep with `features = ["zeroize"]`. The latter is required because `digest/zeroize` does not propagate `hybrid-array/zeroize`, leaving `Array: Zeroize` unsatisfied without the direct dep. Both are gated behind the existing `kdf` feature. `(*result)[..]` explicit dereferences are necessary because `Zeroizing` implements `Deref` but not `Index`. New test functions verified against `openssl kdf PKCS12KDF`: - `pkcs12_key_derive_empty_password`: empty UTF-8 password (BMP null-terminates to [0x00,0x00]); covers all three key types plus a key_len=1 prefix-consistency check. - `pkcs12_key_derive_long_salt`: 72-byte salt spanning two 64-byte SHA-256 blocks (slen=128), exercising the S-padding loop beyond one diversifier block. Breaking change: `derive_key`, `derive_key_bmp`, and `derive_key_utf8` now return `Zeroizing>` instead of `Vec`. Callers that stored the result as `Vec` or compared it directly with `[u8; N]` will need to call `.as_slice()` or deref-coerce. Most read-only callers are unaffected due to `Deref>`. Pre-existing issue (not introduced here): `rounds: i32` — passing 0 or a negative value silently behaves as `rounds = 1` (one hash iteration). --- Cargo.lock | 3 + pkcs12/Cargo.toml | 10 ++- pkcs12/src/kdf.rs | 41 ++++++++---- pkcs12/tests/kdf.rs | 155 +++++++++++++++++++++++++++++++++++--------- 4 files changed, 166 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d106c65e0..196ccc1ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -547,6 +548,7 @@ dependencies = [ "const-oid", "crypto-common", "ctutils", + "zeroize", ] [[package]] @@ -1061,6 +1063,7 @@ dependencies = [ "der", "digest", "hex-literal", + "hybrid-array", "pkcs5", "pkcs8", "sha2", diff --git a/pkcs12/Cargo.toml b/pkcs12/Cargo.toml index d2afabe8e..9f13720ed 100644 --- a/pkcs12/Cargo.toml +++ b/pkcs12/Cargo.toml @@ -23,7 +23,13 @@ const-oid = { version = "0.10", features = ["db"], default-features = false } cms = { version = "=0.3.0-pre.2", default-features = false } # optional dependencies -digest = { version = "0.11", features = ["alloc"], optional = true } +# "zeroize" feature enables zeroing of the digest's internal block buffer, which +# holds input fragments of the id||salt||password data between Update calls. +digest = { version = "0.11", features = ["alloc", "zeroize"], optional = true } +# digest/zeroize does not propagate hybrid-array/zeroize, so Array: Zeroize +# is not satisfied without this direct dep. If digest ever adds hybrid-array/zeroize to +# its own zeroize feature, this dep can be removed. +hybrid-array = { version = "0.4", features = ["zeroize"], optional = true } zeroize = { version = "1.8.1", optional = true, default-features = false } [dev-dependencies] @@ -35,7 +41,7 @@ whirlpool = "0.11" [features] default = ["pem"] -kdf = ["dep:digest", "zeroize/alloc"] +kdf = ["dep:digest", "dep:hybrid-array", "zeroize/alloc"] pem = ["der/pem", "x509-cert/pem"] [package.metadata.docs.rs] diff --git a/pkcs12/src/kdf.rs b/pkcs12/src/kdf.rs index 3c1102707..83924a62f 100644 --- a/pkcs12/src/kdf.rs +++ b/pkcs12/src/kdf.rs @@ -34,6 +34,8 @@ pub enum Pkcs12KeyType { /// Derives `key` of type `id` from `pass` and `salt` with length `key_len` using `rounds` /// iterations of the algorithm /// `pass` must be a utf8 string. +/// +/// The returned key is wrapped in [`Zeroizing`] and will be erased from memory when dropped. /// ```rust /// let key = pkcs12::kdf::derive_key_utf8::("top-secret", &[0x1, 0x2, 0x3, 0x4], /// pkcs12::kdf::Pkcs12KeyType::EncryptionKey, 1000, 32); @@ -44,7 +46,7 @@ pub fn derive_key_utf8( id: Pkcs12KeyType, rounds: i32, key_len: usize, -) -> der::Result> +) -> der::Result>> where D: Digest + FixedOutputReset + BlockSizeUser, { @@ -52,14 +54,20 @@ where Ok(derive_key_bmp::(password_bmp, salt, id, rounds, key_len)) } -/// Derive +/// Derives a key from a BMP-encoded password and salt using the PKCS#12 KDF. +/// +/// The password must already be encoded as a [`BmpString`]; use [`derive_key_utf8`] +/// to pass a UTF-8 `&str` directly. The null terminator required by RFC 7292 +/// Appendix B is appended automatically before calling [`derive_key`]. +/// +/// The returned key is wrapped in [`Zeroizing`] and will be erased from memory when dropped. pub fn derive_key_bmp( password: BmpString, salt: &[u8], id: Pkcs12KeyType, rounds: i32, key_len: usize, -) -> Vec +) -> Zeroizing> where D: Digest + FixedOutputReset + BlockSizeUser, { @@ -75,6 +83,8 @@ where /// 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. +/// +/// The returned key is wrapped in [`Zeroizing`] and will be erased from memory when dropped. /// ```rust /// let key = pkcs12::kdf::derive_key_utf8::("top-secret", &[0x1, 0x2, 0x3, 0x4], /// pkcs12::kdf::Pkcs12KeyType::EncryptionKey, 1000, 32); @@ -85,7 +95,7 @@ pub fn derive_key( id: Pkcs12KeyType, rounds: i32, key_len: usize, -) -> Vec +) -> Zeroizing> where D: Digest + FixedOutputReset + BlockSizeUser, { @@ -108,7 +118,10 @@ where let slen = block_size * salt.len().div_ceil(block_size); let plen = block_size * pass.len().div_ceil(block_size); let ilen = slen + plen; - let mut init_key = vec![0u8; ilen]; + // Zeroizing ensures the S||P buffer (which contains password material) is + // wiped on drop, including on panic unwind. The explicit init_key.zeroize() + // below is retained for eager zeroing at function exit. + let mut init_key = Zeroizing::new(vec![0u8; ilen]); // 2. Concatenate copies of the salt together to create a string S of // length v(ceiling(s/v)) bits (the final copy of the salt may be // truncated to create S). Note that if the salt is the empty @@ -130,7 +143,8 @@ where let mut m = key_len; let mut n = 0; - let mut out = vec![0u8; key_len]; + // Zeroizing ensures key material in `out` is wiped when the caller drops it. + let mut out = Zeroizing::new(vec![0u8; key_len]); // 5. Set c=ceiling(n/u) // 6. For i=1, 2, ..., c, do the following: // [ Instead of following this approach, we use an infinite loop and @@ -140,10 +154,15 @@ where // H(H(H(... H(D||I)))) ::update(&mut digest, &id_block); ::update(&mut digest, &init_key); - let mut result = digest.finalize_fixed_reset(); + // Zeroizing ensures each intermediate hash value is wiped when replaced + // by the next iteration (via Drop on reassignment) and when the final + // value goes out of scope at the end of derive_key. + // `(*result)[..]`: Zeroizing implements Deref but not Index, + // so the explicit deref is required to index into the wrapped Array. + let mut result = Zeroizing::new(digest.finalize_fixed_reset()); for _ in 1..rounds { - ::update(&mut digest, &result[0..output_size]); - result = digest.finalize_fixed_reset(); + ::update(&mut digest, &(*result)[0..output_size]); + result = Zeroizing::new(digest.finalize_fixed_reset()); } // 7. Concateate A_1, A_2, ..., A_c together to form a pseudorandom @@ -151,7 +170,7 @@ where // [ Instead of storing all Ais and concatenating later, we concatenate // them immediately ] let new_bytes_num = m.min(output_size); - out[n..n + new_bytes_num].copy_from_slice(&result[0..new_bytes_num]); + out[n..n + new_bytes_num].copy_from_slice(&(*result)[0..new_bytes_num]); n += new_bytes_num; if m <= new_bytes_num { break; @@ -170,7 +189,7 @@ where let mut c = 1_u16; let mut k = block_size - 1; loop { - c += init_key[k + j] as u16 + result[k % output_size] as u16; + c += init_key[k + j] as u16 + (*result)[k % output_size] as u16; init_key[j + k] = (c & 0x00ff) as u8; c >>= 8; if k == 0 { diff --git a/pkcs12/tests/kdf.rs b/pkcs12/tests/kdf.rs index 6c5004125..9c20d904c 100644 --- a/pkcs12/tests/kdf.rs +++ b/pkcs12/tests/kdf.rs @@ -19,19 +19,23 @@ fn pkcs12_key_derive_sha256() { 100, 32 ) - .unwrap(), - hex!("fae4d4957a3cc781e1180b9d4fb79c1e0c8579b746a3177e5b0768a3118bf863") + .unwrap() + .as_slice(), + hex!("fae4d4957a3cc781e1180b9d4fb79c1e0c8579b746a3177e5b0768a3118bf863").as_slice() ); assert_eq!( - derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 32).unwrap(), - hex!("e5ff813bc6547de5155b14d2fada85b3201a977349db6e26ccc998d9e8f83d6c") + derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 32) + .unwrap() + .as_slice(), + hex!("e5ff813bc6547de5155b14d2fada85b3201a977349db6e26ccc998d9e8f83d6c").as_slice() ); assert_eq!( derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Mac, 100, 32) - .unwrap(), - hex!("136355ed9434516682534f46d63956db5ff06b844702c2c1f3b46321e2524a4d") + .unwrap() + .as_slice(), + hex!("136355ed9434516682534f46d63956db5ff06b844702c2c1f3b46321e2524a4d").as_slice() ); assert_eq!( @@ -42,19 +46,23 @@ fn pkcs12_key_derive_sha256() { 100, 20 ) - .unwrap(), - hex!("fae4d4957a3cc781e1180b9d4fb79c1e0c8579b7") + .unwrap() + .as_slice(), + hex!("fae4d4957a3cc781e1180b9d4fb79c1e0c8579b7").as_slice() ); assert_eq!( - derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 20).unwrap(), - hex!("e5ff813bc6547de5155b14d2fada85b3201a9773") + derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 20) + .unwrap() + .as_slice(), + hex!("e5ff813bc6547de5155b14d2fada85b3201a9773").as_slice() ); assert_eq!( derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Mac, 100, 20) - .unwrap(), - hex!("136355ed9434516682534f46d63956db5ff06b84") + .unwrap() + .as_slice(), + hex!("136355ed9434516682534f46d63956db5ff06b84").as_slice() ); assert_eq!( @@ -65,19 +73,23 @@ fn pkcs12_key_derive_sha256() { 100, 12 ) - .unwrap(), - hex!("fae4d4957a3cc781e1180b9d") + .unwrap() + .as_slice(), + hex!("fae4d4957a3cc781e1180b9d").as_slice() ); assert_eq!( - derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 12).unwrap(), - hex!("e5ff813bc6547de5155b14d2") + derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 100, 12) + .unwrap() + .as_slice(), + hex!("e5ff813bc6547de5155b14d2").as_slice() ); assert_eq!( derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Mac, 100, 12) - .unwrap(), - hex!("136355ed9434516682534f46") + .unwrap() + .as_slice(), + hex!("136355ed9434516682534f46").as_slice() ); assert_eq!( @@ -88,20 +100,23 @@ fn pkcs12_key_derive_sha256() { 1000, 32 ) - .unwrap(), - hex!("2b95a0569b63f641fae1efca32e84db3699ab74540628ba66283b58cf5400527") + .unwrap() + .as_slice(), + hex!("2b95a0569b63f641fae1efca32e84db3699ab74540628ba66283b58cf5400527").as_slice() ); assert_eq!( derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Iv, 1000, 32) - .unwrap(), - hex!("6472c0ebad3fab4123e8b5ed7834de21eeb20187b3eff78a7d1cdffa4034851d") + .unwrap() + .as_slice(), + hex!("6472c0ebad3fab4123e8b5ed7834de21eeb20187b3eff78a7d1cdffa4034851d").as_slice() ); assert_eq!( derive_key_utf8::(PASS_SHORT, &SALT_INC, Pkcs12KeyType::Mac, 1000, 32) - .unwrap(), - hex!("3f9113f05c30a996c4a516409bdac9d065f44296ccd52bb75de3fcfdbe2bf130") + .unwrap() + .as_slice(), + hex!("3f9113f05c30a996c4a516409bdac9d065f44296ccd52bb75de3fcfdbe2bf130").as_slice() ); assert_eq!( @@ -112,10 +127,12 @@ fn pkcs12_key_derive_sha256() { 1000, 100 ) - .unwrap(), + .unwrap() + .as_slice(), hex!( "2b95a0569b63f641fae1efca32e84db3699ab74540628ba66283b58cf5400527d8d0ebe2ccbf768c51c4d8fbd1bb156be06c1c59cbb69e44052ffc37376fdb47b2de7f9e543de9d096d8e5474b220410ff1c5d8bb7e5bc0f61baeaa12fd0da1d7a970172" ) + .as_slice() ); assert_eq!( @@ -126,10 +143,12 @@ fn pkcs12_key_derive_sha256() { 1000, 200 ) - .unwrap(), + .unwrap() + .as_slice(), hex!( "2b95a0569b63f641fae1efca32e84db3699ab74540628ba66283b58cf5400527d8d0ebe2ccbf768c51c4d8fbd1bb156be06c1c59cbb69e44052ffc37376fdb47b2de7f9e543de9d096d8e5474b220410ff1c5d8bb7e5bc0f61baeaa12fd0da1d7a9701729cea6014d7fe62a2ed926dc36b61307f119d64edbceb5a9c58133bbf75ba0bef000a1a5180e4b1de7d89c89528bcb7899a1e46fd4da0d9de8f8e65e8d0d775e33d1247e76d596a34303161b219f39afda448bf518a2835fc5e28f0b55a1b6137a2c70cf7" ) + .as_slice() ); } @@ -146,8 +165,9 @@ fn pkcs12_key_derive_sha512() { 100, 32 ) - .unwrap(), - hex!("b14a9f01bfd9dce4c9d66d2fe9937e5fd9f1afa59e370a6fa4fc81c1cc8ec8ee") + .unwrap() + .as_slice(), + hex!("b14a9f01bfd9dce4c9d66d2fe9937e5fd9f1afa59e370a6fa4fc81c1cc8ec8ee").as_slice() ); } @@ -164,8 +184,83 @@ fn pkcs12_key_derive_whirlpool() { 100, 32 ) - .unwrap(), - hex!("3324282adb468bff0734d3b7e399094ec8500cb5b0a3604055da107577aaf766") + .unwrap() + .as_slice(), + hex!("3324282adb468bff0734d3b7e399094ec8500cb5b0a3604055da107577aaf766").as_slice() + ); +} + +/// Empty password: derive_key_utf8("") → BmpString null-terminates to [0x00, 0x00], +/// so the password contribution to I is one block of that two-byte sequence repeated. +/// Vectors verified with `openssl kdf -kdfopt hexpass:0000 ...`. +#[test] +fn pkcs12_key_derive_empty_password() { + const SALT_INC: [u8; 8] = [0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]; + + assert_eq!( + derive_key_utf8::("", &SALT_INC, Pkcs12KeyType::EncryptionKey, 100, 32) + .unwrap() + .as_slice(), + hex!("4a8bd650518803030f2e71ae5665d0f8c59f498feede48a0ccad0e027ef1b4e1").as_slice() + ); + + assert_eq!( + derive_key_utf8::("", &SALT_INC, Pkcs12KeyType::Iv, 100, 32) + .unwrap() + .as_slice(), + hex!("43de84225ec6ee96207e2d3d00d6da341ff8750da1ce792090cc4f7f4be6906b").as_slice() + ); + + assert_eq!( + derive_key_utf8::("", &SALT_INC, Pkcs12KeyType::Mac, 100, 32) + .unwrap() + .as_slice(), + hex!("b96a85b509ed9ce0a5d28853c4221291c7c05fe01c4837938893128c4f8c866c").as_slice() + ); + + // Verify key_len=1 is a prefix of key_len=32 (algorithm is a prefix construction). + assert_eq!( + derive_key_utf8::("", &SALT_INC, Pkcs12KeyType::EncryptionKey, 100, 1) + .unwrap() + .as_slice(), + hex!("4a").as_slice() + ); +} + +/// Long salt (72 bytes, two SHA-256 blocks of 64): exercises the S-padding loop when +/// the salt overflows a single diversifier block. +/// Vectors verified with `openssl kdf -kdfopt hexsalt:<72 bytes> ...`. +#[test] +fn pkcs12_key_derive_long_salt() { + const PASS: &str = "ge@\u{00e4}heim"; + // 72-byte salt (0x01..=0x48): spans two 64-byte SHA-256 blocks so slen=128. + const SALT_LONG: [u8; 72] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, + 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, + 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, + 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + ]; + + assert_eq!( + derive_key_utf8::(PASS, &SALT_LONG, Pkcs12KeyType::EncryptionKey, 100, 32) + .unwrap() + .as_slice(), + hex!("dcbd2bae16461c4e784d7fea6d186b8f8044257b354209caace2df99b4f1c5a9").as_slice() + ); + + assert_eq!( + derive_key_utf8::(PASS, &SALT_LONG, Pkcs12KeyType::Iv, 100, 32) + .unwrap() + .as_slice(), + hex!("8e3d55eb2c664926aacd16312aff0b33ec793a2189468704bd63e470bddedcae").as_slice() + ); + + assert_eq!( + derive_key_utf8::(PASS, &SALT_LONG, Pkcs12KeyType::Mac, 100, 32) + .unwrap() + .as_slice(), + hex!("a4f6653f89f2a599dd07e02277fdebdabc1fa22e205a73e23cd406980b6784d4").as_slice() ); }