Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project will be documented in this file.

## [5.15.0] - 2026-01-09

### ⚠️ WARNING

Any previously **manually** exported keys in **JSON** format must be manually updated if they have been previously wrapped with AES. This can be done using the following command:

```bash
sed -i 's/NISTKeyWrap/AESKeyWrapPadding/g' your_exported_key.json
```

## [5.14.1] - 2025-12-26

### 🚀 Features
Expand Down
1 change: 0 additions & 1 deletion crate/cli/src/actions/kms/symmetric/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,6 @@ impl DecryptAction {
// determine the DEM parameters
let dem_cryptographic_parameters: CryptographicParameters =
data_encryption_algorithm.into();

trace!("dek length {}", dek.len());
let sym_cipher = SymCipher::from_algorithm_and_key_size(
dem_cryptographic_parameters
Expand Down
6 changes: 6 additions & 0 deletions crate/cli/src/actions/kms/symmetric/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub enum KeyEncryptionAlgorithm {
AesXts,
#[cfg(feature = "non-fips")]
AesGcmSiv,
RFC3394,
RFC5649,
}

Expand Down Expand Up @@ -79,6 +80,11 @@ impl From<KeyEncryptionAlgorithm> for CryptographicParameters {
..Self::default()
},
KeyEncryptionAlgorithm::RFC5649 => Self {
cryptographic_algorithm: Some(CryptographicAlgorithm::AES),
block_cipher_mode: Some(BlockCipherMode::AESKeyWrapPadding),
..Self::default()
},
KeyEncryptionAlgorithm::RFC3394 => Self {
cryptographic_algorithm: Some(CryptographicAlgorithm::AES),
block_cipher_mode: Some(BlockCipherMode::NISTKeyWrap),
..Self::default()
Expand Down
6 changes: 5 additions & 1 deletion crate/cli/src/tests/kms/secret_data/create_secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,11 @@ pub(crate) async fn test_secret_data_export_with_different_wrapping_algorithms()
.await?;

// Test different wrapping algorithms
let wrapping_algorithms = [WrappingAlgorithm::NistKeyWrap, WrappingAlgorithm::AesGCM];
let wrapping_algorithms = [
WrappingAlgorithm::AESKeyWrapPadding, // RFC 5649
WrappingAlgorithm::NistKeyWrap, // RFC 3394
WrappingAlgorithm::AesGCM,
];

for (i, algorithm) in wrapping_algorithms.iter().enumerate() {
let wrapped_file = tmp_path.join(format!("wrapped_secret_{i}.json"));
Expand Down
2 changes: 1 addition & 1 deletion crate/cli/src/tests/kms/shared/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ pub(crate) async fn test_export_wrapped() -> KmsCliResult<()> {
key_id: Some(private_key_id.to_string()),
key_file: tmp_path.join("output.export"),
wrap_key_id: Some(sym_key_id.to_string()),
wrapping_algorithm: Some(WrappingAlgorithm::NistKeyWrap),
wrapping_algorithm: Some(WrappingAlgorithm::AESKeyWrapPadding),
..Default::default()
}
.run(ctx.get_owner_client())
Expand Down
6 changes: 5 additions & 1 deletion crate/cli/src/tests/kms/shared/export_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ pub(crate) async fn test_wrap_on_export_unwrap_on_import() -> KmsCliResult<()> {
.to_string();

// Export and import the key with different block cipher modes
for wrapping_algorithm in [WrappingAlgorithm::AesGCM, WrappingAlgorithm::NistKeyWrap] {
for wrapping_algorithm in [
WrappingAlgorithm::AesGCM,
WrappingAlgorithm::NistKeyWrap,
WrappingAlgorithm::AESKeyWrapPadding,
] {
debug!("wrapping algorithm: {wrapping_algorithm:?}",);
ExportSecretDataOrKeyAction {
key_id: Some(dek_id.clone()),
Expand Down
8 changes: 7 additions & 1 deletion crate/client_utils/src/export_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ pub enum ExportKeyFormat {
#[derive(Debug, Clone, PartialEq, Eq, EnumString, ValueEnum)]
#[strum(serialize_all = "kebab-case")]
pub enum WrappingAlgorithm {
NistKeyWrap,
AESKeyWrapPadding, // RFC 5649
NistKeyWrap, // RFC 3394
AesGCM,
RsaPkcsV15Sha1,
RsaPkcsV15,
Expand Down Expand Up @@ -155,6 +156,11 @@ pub fn prepare_key_export_elements(
wrapping_algorithm
.as_ref()
.map(|wrapping_algorithm| match wrapping_algorithm {
WrappingAlgorithm::AESKeyWrapPadding => CryptographicParameters {
cryptographic_algorithm: Some(CryptographicAlgorithm::AES),
block_cipher_mode: Some(BlockCipherMode::AESKeyWrapPadding),
..CryptographicParameters::default()
},
WrappingAlgorithm::NistKeyWrap => CryptographicParameters {
cryptographic_algorithm: Some(CryptographicAlgorithm::AES),
block_cipher_mode: Some(BlockCipherMode::NISTKeyWrap),
Expand Down
20 changes: 9 additions & 11 deletions crate/client_utils/src/symmetric_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,15 @@ pub const AES_256_GCM_SIV_IV_LENGTH: usize = 12;
#[cfg(feature = "non-fips")]
pub const AES_256_GCM_SIV_MAC_LENGTH: usize = 16;

/// RFC 5649 with a 16-byte KEK.
pub const RFC5649_16_KEY_LENGTH: usize = 16;
// RFC 5649 IV is actually a fixed overhead
pub const RFC5649_16_IV_LENGTH: usize = 0;
/// RFC5649 has no authentication.
pub const RFC5649_16_MAC_LENGTH: usize = 0;
/// RFC 5649 with a 32-byte KEK.
pub const RFC5649_32_KEY_LENGTH: usize = 32;
// RFC 3394 IV is actually a fixed overhead
pub const RFC3394_IV_LENGTH: usize = 0;
/// RFC3394 has no authentication.
pub const RFC3394_MAC_LENGTH: usize = 0;

// RFC 5649 IV is actually a fixed overhead
pub const RFC5649_32_IV_LENGTH: usize = 0;
pub const RFC5649_IV_LENGTH: usize = 0;
/// RFC5649 has no authentication.
pub const RFC5649_32_MAC_LENGTH: usize = 0;
pub const RFC5649_MAC_LENGTH: usize = 0;

#[cfg(feature = "non-fips")]
/// Chacha20-Poly1305 key length in bytes.
Expand Down Expand Up @@ -156,7 +153,8 @@ pub fn parse_decrypt_elements(
}
BlockCipherMode::CBC => (AES_128_CBC_IV_LENGTH, AES_128_CBC_MAC_LENGTH),
BlockCipherMode::XTS => (AES_128_XTS_TWEAK_LENGTH, AES_128_XTS_MAC_LENGTH),
BlockCipherMode::NISTKeyWrap => (RFC5649_16_IV_LENGTH, RFC5649_16_MAC_LENGTH),
BlockCipherMode::AESKeyWrapPadding => (RFC5649_IV_LENGTH, RFC5649_MAC_LENGTH),
BlockCipherMode::NISTKeyWrap => (RFC3394_IV_LENGTH, RFC3394_MAC_LENGTH),
_ => {
return Err(UtilsError::Default(
"Unsupported block cipher mode".to_owned(),
Expand Down
2 changes: 1 addition & 1 deletion crate/crypto/src/crypto/rsa/ckm_rsa_aes_key_wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ FQIDAQAB
crypto_bail!("test_for_byok: RFC5649 pkeyutl failed: {output:?}");
}
let rfc5649_encapsulation = fs::read(&rfc5649_encapsulation_file)?;
// Check against our implementation of NistKeyWrap
// Check against our implementation of AESKeyWrapPadding
let rec_secret_bytes = rfc5649_unwrap(
&rfc5649_encapsulation,
&hex::decode(ephemeral)
Expand Down
2 changes: 1 addition & 1 deletion crate/crypto/src/crypto/symmetric/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
pub mod symmetric_ciphers;

#[expect(clippy::indexing_slicing)]
pub mod rfc3394;
pub mod rfc5649;

#[cfg(feature = "non-fips")]
Expand Down
208 changes: 208 additions & 0 deletions crate/crypto/src/crypto/symmetric/rfc3394.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! AES Key Wrap (RFC 3394) without padding (KW) via rust-openssl.
//! Please prefer using the RFC 5649, as it's the current standard. This implementation is only made available to comply with API that still support legacy encryption standards.
//!
//! Spec references:
//! - RFC 3394: <https://datatracker.ietf.org/doc/html/rfc3394>
//! - NIST SP 800-38F: <https://csrc.nist.gov/pubs/sp/800/38/f/final>
//!
//! Notes:
//! - Input must be a multiple of 8 bytes and at least 16 bytes (n >= 2 blocks).
//! - No padding is performed; for non-8-byte input lengths, use RFC 5649 (KWP).
use openssl::cipher::{Cipher, CipherRef};
use openssl::cipher_ctx::CipherCtx;
use zeroize::Zeroizing;

use crate::error::CryptoError;
use crate::error::result::CryptoResult;

const AES_BLOCK_SIZE: usize = 16; // 128-bit
const AES_WRAP_BLOCK_SIZE: usize = 8; // 64-bit

fn select_cipher(kek: &[u8]) -> CryptoResult<&CipherRef> {
Ok(match kek.len() {
16 => Cipher::aes_128_wrap(),
24 => Cipher::aes_192_wrap(),
32 => Cipher::aes_256_wrap(),
_ => {
return Err(CryptoError::InvalidSize(
"The KEK size should be 16, 24 or 32 bytes".to_owned(),
));
}
})
}

pub fn rfc3394_wrap(plaintext: &[u8], kek: &[u8]) -> CryptoResult<Vec<u8>> {
let n_bytes = plaintext.len();

// RFC 3394 requires plaintext to be at least 16 bytes and a multiple of 8 bytes.
if !n_bytes.is_multiple_of(AES_WRAP_BLOCK_SIZE) || n_bytes < 2 * AES_WRAP_BLOCK_SIZE {
return Err(CryptoError::InvalidSize(
"The plaintext size should be >= 16 and a multiple of 8".to_owned(),
));
}

let cipher = select_cipher(kek)?;

// Initialize cipher context for encryption
let mut ctx = CipherCtx::new()?;
ctx.encrypt_init(Some(cipher), Some(kek), None)?;

// Allocate output buffer: wrapped size is plaintext + 8 bytes (IV) + 2 extra blocks for cipher_final.
// The extra blocks will not propagate to the result as it's truncated to the actual size. Due to how the openssl library is programmed,
// not adding at least 1 extra block results in a panic. We chose to add two because that's how the openssl library operates when using this cipher.
let mut ciphertext = vec![0_u8; n_bytes + AES_WRAP_BLOCK_SIZE + (AES_BLOCK_SIZE * 2)];

// Perform the key wrap operation
let mut written = ctx.cipher_update(plaintext, Some(&mut ciphertext))?;
written += ctx.cipher_final(ciphertext.get_mut(written..).ok_or_else(|| {
CryptoError::IndexingSlicing("Buffer too small for cipher_final".to_owned())
})?)?;

// Truncate to actual output size.
ciphertext.truncate(written);

Ok(ciphertext)
}

pub fn rfc3394_unwrap(ciphertext: &[u8], kek: &[u8]) -> CryptoResult<Zeroizing<Vec<u8>>> {
let n_bytes = ciphertext.len();

// RFC 3394 requires ciphertext to be at least 24 bytes (16 bytes plaintext + 8 bytes IV) and a multiple of 8.
if !n_bytes.is_multiple_of(AES_WRAP_BLOCK_SIZE) || n_bytes < 3 * AES_WRAP_BLOCK_SIZE {
return Err(CryptoError::InvalidSize(
"The ciphertext size should be >= 24 and a multiple of 8".to_owned(),
));
}

let cipher = select_cipher(kek)?;

// Initialize cipher context for decryption.
let mut ctx = CipherCtx::new()?;
ctx.decrypt_init(Some(cipher), Some(kek), None)?;

// Allocate output buffer: unwrapped size is ciphertext - 8 bytes (IV) + extra blocks for cipher_final. Same comments as above.
let mut plaintext = Zeroizing::new(vec![
0_u8;
n_bytes - AES_WRAP_BLOCK_SIZE + (AES_BLOCK_SIZE * 2)
]);

// Perform the key unwrap operation
let mut written = ctx.cipher_update(ciphertext, Some(&mut plaintext))?;
written += ctx.cipher_final(plaintext.get_mut(written..).ok_or_else(|| {
CryptoError::IndexingSlicing("Buffer too small for cipher_final".to_owned())
})?)?;

// Truncate to actual output size.
plaintext.truncate(written);

Ok(plaintext)
}

#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::*;
use zeroize::Zeroizing;

/// Helper to run wrap/unwrap roundtrip test
fn test_wrap_unwrap(kek_hex: &str, plaintext_hex: &str, expected_ciphertext_hex: &str) {
#[cfg(not(feature = "non-fips"))]
openssl::provider::Provider::load(None, "fips").unwrap();
let kek = hex::decode(kek_hex).unwrap();
let p = hex::decode(plaintext_hex).unwrap();
let c_expected = hex::decode(expected_ciphertext_hex).unwrap();

let c = rfc3394_wrap(&p, &kek).unwrap();
assert_eq!(c, c_expected, "Wrap output mismatch");

let p_unwrapped = rfc3394_unwrap(&c, &kek).unwrap();
assert_eq!(p_unwrapped, Zeroizing::from(p), "Unwrap output mismatch");
}

// RFC 3394 test vectors with AES-128 KEK
#[test]
fn test_rfc3394_aes128_kek() {
// Section 4.1: 128-bit plaintext
test_wrap_unwrap(
"000102030405060708090A0B0C0D0E0F",
"00112233445566778899AABBCCDDEEFF",
"1FA68B0A8112B447AEF34BD8FB5A7B829D3E862371D2CFE5",
);
}

// RFC 3394 test vectors with AES-192 KEK
#[test]
fn test_rfc3394_aes192_kek() {
let kek = "000102030405060708090A0B0C0D0E0F1011121314151617";

// Section 4.2: 128-bit plaintext
test_wrap_unwrap(
kek,
"00112233445566778899AABBCCDDEEFF",
"96778B25AE6CA435F92B5B97C050AED2468AB8A17AD84E5D",
);

// Section 4.4: 192-bit plaintext
test_wrap_unwrap(
kek,
"00112233445566778899AABBCCDDEEFF0001020304050607",
"031D33264E15D33268F24EC260743EDCE1C6C7DDEE725A936BA814915C6762D2",
);
}

// RFC 3394 test vectors with AES-256 KEK
#[test]
fn test_rfc3394_aes256_kek() {
let kek = "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F";

// Section 4.3: 128-bit plaintext
test_wrap_unwrap(
kek,
"00112233445566778899AABBCCDDEEFF",
"64E8C3F9CE0F5BA263E9777905818A2A93C8191E7D6E8AE7",
);

// Section 4.5: 192-bit plaintext
test_wrap_unwrap(
kek,
"00112233445566778899AABBCCDDEEFF0001020304050607",
"A8F9BC1612C68B3FF6E6F4FBE30E71E4769C8B80A32CB8958CD5D17D6B254DA1",
);

// Section 4.6: 256-bit plaintext
test_wrap_unwrap(
kek,
"00112233445566778899AABBCCDDEEFF000102030405060708090A0B0C0D0E0F",
"28C9F404C4B810F4CBCCB35CFB87F8263F5786E2D80ED326CBC7F0E71A99F43BFB988B9B7A02DD21",
);
}

// Additional sanity: error cases
#[test]
fn test_errors() {
#[cfg(not(feature = "non-fips"))]
openssl::provider::Provider::load(None, "fips").unwrap();

// KEK length invalid
let kek_bad = [0x00_u8; 1];
let p16 = [0x11_u8; 16];
rfc3394_wrap(&p16, &kek_bad).unwrap_err();
let c24 = [0x22_u8; 24];
rfc3394_unwrap(&c24, &kek_bad).unwrap_err();

// Plaintext not multiple of 8 or too small
let kek16 = [0x01_u8; 16];
let p15 = [0x33_u8; 15];
rfc3394_wrap(&p15, &kek16).unwrap_err();
let p8 = [0x44_u8; 8];
rfc3394_wrap(&p8, &kek16).unwrap_err();

// Ciphertext too small
let c16 = [0x55_u8; 16];
rfc3394_unwrap(&c16, &kek16).unwrap_err();

// Ciphertext not multiple of 8
let c23 = [0x66_u8; 23];
rfc3394_unwrap(&c23, &kek16).unwrap_err();
}
}
Loading
Loading