From 9c0aa3543a10f3a858c6a462789405d145eefe54 Mon Sep 17 00:00:00 2001 From: Mike Mulchrone Date: Tue, 7 Jul 2026 22:05:50 -0400 Subject: [PATCH] aes gcm with aad --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/EXAMPLES.md | 40 +++++ src/symmetric/aes.rs | 76 ++++++++- src/symmetric/cas_symmetric_encryption.rs | 10 ++ tests/symmetric.rs | 182 ++++++++++++++++++++-- 6 files changed, 298 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5061ef1..898797e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,7 +173,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cas-lib" -version = "0.2.87" +version = "0.2.88" dependencies = [ "aes-gcm", "aes-gcm-siv", diff --git a/Cargo.toml b/Cargo.toml index c66d7cd..de2b9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cas-lib" -version = "0.2.87" +version = "0.2.88" edition = "2021" description = "A function wrapper layer for RustCrypto and Dalek-Cryptography. Intended to be used in FFI situations with a global heap deallactor at the top level project." license = "Apache-2.0" diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index c50dcc5..304c9b1 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -137,6 +137,46 @@ fn main() { } ``` +### AES GCM Mode with AAD + +Both AES types also implement the `CASAES256AadEncryption` / `CASAES128AadEncryption` +traits, which take additional authenticated data (AAD). The AAD is not encrypted, but +it is bound to the authentication tag: decryption only succeeds when the same AAD is +supplied, so any tampering with it is detected. + +```rust +use cas_lib::symmetric::{ + aes::CASAES256, + cas_symmetric_encryption::{CASAES256AadEncryption, CASAES256Encryption}, +}; + +fn main() { + let plaintext: Vec = std::fs::read("MikeMulchrone_Resume2024.docx").unwrap(); + let aad = b"file-id:1234".to_vec(); + + let aes_nonce = ::generate_nonce(); + let aes_key = ::generate_key(); + let encrypted_bytes = ::encrypt_plaintext_with_aad( + aes_key.clone(), + aes_nonce.clone(), + plaintext.clone(), + aad.clone(), + ) + .unwrap(); + + // Decryption requires the same AAD; a different AAD returns an error. + let decrypted_bytes = ::decrypt_ciphertext_with_aad( + aes_key, + aes_nonce, + encrypted_bytes, + aad, + ) + .unwrap(); + + assert_eq!(plaintext, decrypted_bytes); +} +``` + ### ChaCha20-Poly1305 ```rust use std::{fs::File, io::Write, path::Path}; diff --git a/src/symmetric/aes.rs b/src/symmetric/aes.rs index c7455de..29d5bea 100644 --- a/src/symmetric/aes.rs +++ b/src/symmetric/aes.rs @@ -4,14 +4,16 @@ use hkdf::Hkdf; use rand::{RngCore, rngs::OsRng}; use aes_gcm::{ - aead::{Aead}, + aead::{Aead, Payload}, Aes128Gcm, Aes256Gcm, KeyInit, Nonce, }; use sha2::Sha256; use crate::error::{CasError, CasResult}; -use super::cas_symmetric_encryption::{CASAES128Encryption, CASAES256Encryption}; +use super::cas_symmetric_encryption::{ + CASAES128AadEncryption, CASAES128Encryption, CASAES256AadEncryption, CASAES256Encryption, +}; const AES_NONCE_LEN: usize = 12; const AES128_KEY_LEN: usize = 16; @@ -91,6 +93,76 @@ impl CASAES256Encryption for CASAES256 { } } +impl CASAES256AadEncryption for CASAES256 { + + /// Encrypts with AES-256-GCM taking an aes_key, aes_nonce and additional authenticated data + fn encrypt_plaintext_with_aad(aes_key: Vec, nonce: Vec, plaintext: Vec, aad: Vec) -> CasResult> { + if aes_key.len() != AES256_KEY_LEN { + return Err(CasError::InvalidKey); + } + if nonce.len() != AES_NONCE_LEN { + return Err(CasError::InvalidNonce); + } + let key = Key::::from_slice(aes_key.as_slice()); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce.as_slice()); + cipher + .encrypt(nonce, Payload { msg: plaintext.as_slice(), aad: aad.as_slice() }) + .map_err(|_| CasError::EncryptionFailed) + } + + /// Decrypts with AES-256-GCM taking an aes_key, aes_nonce and additional authenticated data + fn decrypt_ciphertext_with_aad(aes_key: Vec, nonce: Vec, ciphertext: Vec, aad: Vec) -> CasResult> { + if aes_key.len() != AES256_KEY_LEN { + return Err(CasError::InvalidKey); + } + if nonce.len() != AES_NONCE_LEN { + return Err(CasError::InvalidNonce); + } + let key = Key::::from_slice(aes_key.as_slice()); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce.as_slice()); + cipher + .decrypt(nonce, Payload { msg: ciphertext.as_slice(), aad: aad.as_slice() }) + .map_err(|_| CasError::DecryptionFailed) + } +} + +impl CASAES128AadEncryption for CASAES128 { + + /// Encrypts with AES-128-GCM taking an aes_key, aes_nonce and additional authenticated data + fn encrypt_plaintext_with_aad(aes_key: Vec, nonce: Vec, plaintext: Vec, aad: Vec) -> CasResult> { + if aes_key.len() != AES128_KEY_LEN { + return Err(CasError::InvalidKey); + } + if nonce.len() != AES_NONCE_LEN { + return Err(CasError::InvalidNonce); + } + let key = Key::::from_slice(aes_key.as_slice()); + let cipher = Aes128Gcm::new(key); + let nonce = Nonce::from_slice(nonce.as_slice()); + cipher + .encrypt(nonce, Payload { msg: plaintext.as_slice(), aad: aad.as_slice() }) + .map_err(|_| CasError::EncryptionFailed) + } + + /// Decrypts with AES-128-GCM taking an aes_key, aes_nonce and additional authenticated data + fn decrypt_ciphertext_with_aad(aes_key: Vec, nonce: Vec, ciphertext: Vec, aad: Vec) -> CasResult> { + if aes_key.len() != AES128_KEY_LEN { + return Err(CasError::InvalidKey); + } + if nonce.len() != AES_NONCE_LEN { + return Err(CasError::InvalidNonce); + } + let key = Key::::from_slice(aes_key.as_slice()); + let cipher = Aes128Gcm::new(key); + let nonce = Nonce::from_slice(nonce.as_slice()); + cipher + .decrypt(nonce, Payload { msg: ciphertext.as_slice(), aad: aad.as_slice() }) + .map_err(|_| CasError::DecryptionFailed) + } +} + impl CASAES128Encryption for CASAES128 { /// Generates an AES128 key from a vector. diff --git a/src/symmetric/cas_symmetric_encryption.rs b/src/symmetric/cas_symmetric_encryption.rs index b2d75ab..0ea19e7 100644 --- a/src/symmetric/cas_symmetric_encryption.rs +++ b/src/symmetric/cas_symmetric_encryption.rs @@ -18,6 +18,16 @@ pub trait CASAES128Encryption { fn key_from_vec(key_slice: Vec) -> CasResult>; } +pub trait CASAES256AadEncryption { + fn encrypt_plaintext_with_aad(aes_key: Vec, nonce: Vec, plaintext: Vec, aad: Vec) -> CasResult>; + fn decrypt_ciphertext_with_aad(aes_key: Vec, nonce: Vec, ciphertext: Vec, aad: Vec) -> CasResult>; +} + +pub trait CASAES128AadEncryption { + fn encrypt_plaintext_with_aad(aes_key: Vec, nonce: Vec, plaintext: Vec, aad: Vec) -> CasResult>; + fn decrypt_ciphertext_with_aad(aes_key: Vec, nonce: Vec, ciphertext: Vec, aad: Vec) -> CasResult>; +} + pub trait CASAES256SIVEncryption { fn generate_key() -> Vec; fn encrypt_plaintext(aes_key: Vec, nonce: Vec, plaintext: Vec) -> CasResult>; diff --git a/tests/symmetric.rs b/tests/symmetric.rs index b1c6a62..25fb101 100644 --- a/tests/symmetric.rs +++ b/tests/symmetric.rs @@ -7,7 +7,7 @@ mod common; #[cfg(test)] mod symmetric { use crate::common::temp_output_path; - use cas_lib::{key_exchange::{cas_key_exchange::CASKeyExchange, x25519::X25519}, pqc::{cas_pqc::MlKemKeyPair, ml_kem::{ml_kem_1024_decapsulate, ml_kem_1024_encapsulate, ml_kem_1024_generate}}, symmetric::{aes::CASAES128, cas_symmetric_encryption::{CASAES128Encryption, Chacha20Poly1305Encryption}, chacha20poly1305::CASChacha20Poly1305}}; + use cas_lib::{key_exchange::{cas_key_exchange::CASKeyExchange, x25519::X25519}, pqc::{cas_pqc::MlKemKeyPair, ml_kem::{ml_kem_1024_decapsulate, ml_kem_1024_encapsulate, ml_kem_1024_generate}}, symmetric::{aes::CASAES128, cas_symmetric_encryption::{CASAES128AadEncryption, CASAES128Encryption, CASAES256AadEncryption, Chacha20Poly1305Encryption}, chacha20poly1305::CASChacha20Poly1305}}; use hkdf::Hkdf; use sha2::Sha256; @@ -17,9 +17,11 @@ mod symmetric { struct AesGcmRspCase { key: Vec, iv: Vec, + aad: Vec, pt: Vec, ct: Vec, tag: Vec, + failed: bool, } fn decode_hex(hex: &str) -> Vec { @@ -38,7 +40,7 @@ mod symmetric { line.strip_prefix(prefix).map(|value| value.trim().to_string()) } - fn parse_gcm_encryptable_rsp_cases(path: &str) -> Vec { + fn parse_gcm_rsp_cases(path: &str) -> Vec { let contents = std::fs::read_to_string(path).unwrap(); let mut cases = Vec::new(); @@ -69,21 +71,21 @@ mod symmetric { let tag_hex = tag.take(); let aad_hex = aad.take(); - let is_supported_case = !*failed - && aad_hex.as_deref().unwrap_or("").is_empty() - && iv_hex.as_deref().is_some_and(|value| value.len() == 24) + let is_supported_case = iv_hex.as_deref().is_some_and(|value| value.len() == 24) && tag_hex.as_deref().is_some_and(|value| value.len() == 32) && key_hex.is_some() - && pt_hex.is_some() - && ct_hex.is_some(); + && ct_hex.is_some() + && (pt_hex.is_some() || *failed); if is_supported_case { cases.push(AesGcmRspCase { key: decode_hex(key_hex.unwrap().as_str()), iv: decode_hex(iv_hex.unwrap().as_str()), - pt: decode_hex(pt_hex.unwrap().as_str()), + aad: decode_hex(aad_hex.unwrap_or_default().as_str()), + pt: decode_hex(pt_hex.unwrap_or_default().as_str()), ct: decode_hex(ct_hex.unwrap().as_str()), tag: decode_hex(tag_hex.unwrap().as_str()), + failed: *failed, }); } *failed = false; @@ -160,7 +162,10 @@ mod symmetric { } fn assert_aes_128_gcm_rsp_encrypt_vectors(path: &str) { - let cases = parse_gcm_encryptable_rsp_cases(path); + let cases: Vec = parse_gcm_rsp_cases(path) + .into_iter() + .filter(|case| !case.failed && case.aad.is_empty()) + .collect(); assert!(!cases.is_empty(), "no AES-128-GCM vectors were loaded from {path}"); for case in cases { @@ -179,7 +184,10 @@ mod symmetric { } fn assert_aes_256_gcm_rsp_encrypt_vectors(path: &str) { - let cases = parse_gcm_encryptable_rsp_cases(path); + let cases: Vec = parse_gcm_rsp_cases(path) + .into_iter() + .filter(|case| !case.failed && case.aad.is_empty()) + .collect(); assert!(!cases.is_empty(), "no AES-256-GCM vectors were loaded from {path}"); for case in cases { @@ -197,6 +205,106 @@ mod symmetric { } } + fn assert_aes_128_gcm_rsp_encrypt_aad_vectors(path: &str) { + let cases: Vec = parse_gcm_rsp_cases(path) + .into_iter() + .filter(|case| !case.failed && !case.aad.is_empty()) + .collect(); + assert!(!cases.is_empty(), "no AES-128-GCM AAD vectors were loaded from {path}"); + + for case in cases { + let mut expected = case.ct.clone(); + expected.extend_from_slice(&case.tag); + + let encrypted = ::encrypt_plaintext_with_aad( + case.key.clone(), + case.iv.clone(), + case.pt.clone(), + case.aad.clone(), + ) + .unwrap(); + + assert_eq!(encrypted, expected); + } + } + + fn assert_aes_256_gcm_rsp_encrypt_aad_vectors(path: &str) { + let cases: Vec = parse_gcm_rsp_cases(path) + .into_iter() + .filter(|case| !case.failed && !case.aad.is_empty()) + .collect(); + assert!(!cases.is_empty(), "no AES-256-GCM AAD vectors were loaded from {path}"); + + for case in cases { + let mut expected = case.ct.clone(); + expected.extend_from_slice(&case.tag); + + let encrypted = ::encrypt_plaintext_with_aad( + case.key.clone(), + case.iv.clone(), + case.pt.clone(), + case.aad.clone(), + ) + .unwrap(); + + assert_eq!(encrypted, expected); + } + } + + fn assert_aes_128_gcm_rsp_decrypt_aad_vectors(path: &str) { + let cases = parse_gcm_rsp_cases(path); + assert!(!cases.is_empty(), "no AES-128-GCM vectors were loaded from {path}"); + assert!( + cases.iter().any(|case| case.failed), + "no failing AES-128-GCM vectors were loaded from {path}" + ); + + for case in cases { + let mut ciphertext = case.ct.clone(); + ciphertext.extend_from_slice(&case.tag); + + let decrypted = ::decrypt_ciphertext_with_aad( + case.key.clone(), + case.iv.clone(), + ciphertext, + case.aad.clone(), + ); + + if case.failed { + assert!(decrypted.is_err()); + } else { + assert_eq!(decrypted.unwrap(), case.pt); + } + } + } + + fn assert_aes_256_gcm_rsp_decrypt_aad_vectors(path: &str) { + let cases = parse_gcm_rsp_cases(path); + assert!(!cases.is_empty(), "no AES-256-GCM vectors were loaded from {path}"); + assert!( + cases.iter().any(|case| case.failed), + "no failing AES-256-GCM vectors were loaded from {path}" + ); + + for case in cases { + let mut ciphertext = case.ct.clone(); + ciphertext.extend_from_slice(&case.tag); + + let decrypted = ::decrypt_ciphertext_with_aad( + case.key.clone(), + case.iv.clone(), + ciphertext, + case.aad.clone(), + ); + + if case.failed { + assert!(decrypted.is_err()); + } else { + assert_eq!(decrypted.unwrap(), case.pt); + } + } + } + #[test] fn test_aes_256() { let path = Path::new("tests/test.docx"); @@ -331,4 +439,58 @@ mod symmetric { fn test_aes_256_gcm_rsp_encrypt_vectors() { assert_aes_256_gcm_rsp_encrypt_vectors("tests/data/aes/gcmDecrypt256.rsp"); } + + #[test] + fn test_aes_128_gcm_rsp_encrypt_aad_vectors() { + assert_aes_128_gcm_rsp_encrypt_aad_vectors("tests/data/aes/gcmDecrypt128.rsp"); + } + + #[test] + fn test_aes_256_gcm_rsp_encrypt_aad_vectors() { + assert_aes_256_gcm_rsp_encrypt_aad_vectors("tests/data/aes/gcmDecrypt256.rsp"); + } + + #[test] + fn test_aes_128_gcm_rsp_decrypt_aad_vectors() { + assert_aes_128_gcm_rsp_decrypt_aad_vectors("tests/data/aes/gcmDecrypt128.rsp"); + } + + #[test] + fn test_aes_256_gcm_rsp_decrypt_aad_vectors() { + assert_aes_256_gcm_rsp_decrypt_aad_vectors("tests/data/aes/gcmDecrypt256.rsp"); + } + + #[test] + fn test_aes_256_aad_round_trip() { + let path = Path::new("tests/test.docx"); + let file_bytes: Vec = std::fs::read(path).unwrap(); + let aad = b"cas-lib aes-256-gcm aad".to_vec(); + let aes_nonce = ::generate_nonce(); + let aes_key = ::generate_key(); + let encrypted_bytes = ::encrypt_plaintext_with_aad(aes_key.clone(), aes_nonce.clone(), file_bytes.clone(), aad.clone()).unwrap(); + + let decrypted_bytes = ::decrypt_ciphertext_with_aad(aes_key.clone(), aes_nonce.clone(), encrypted_bytes.clone(), aad).unwrap(); + assert_eq!(file_bytes, decrypted_bytes); + + let tampered_aad = b"tampered aad".to_vec(); + let result = ::decrypt_ciphertext_with_aad(aes_key, aes_nonce, encrypted_bytes, tampered_aad); + assert!(result.is_err()); + } + + #[test] + fn test_aes_128_aad_round_trip() { + let path = Path::new("tests/test.docx"); + let file_bytes: Vec = std::fs::read(path).unwrap(); + let aad = b"cas-lib aes-128-gcm aad".to_vec(); + let aes_nonce = ::generate_nonce(); + let aes_key = ::generate_key(); + let encrypted_bytes = ::encrypt_plaintext_with_aad(aes_key.clone(), aes_nonce.clone(), file_bytes.clone(), aad.clone()).unwrap(); + + let decrypted_bytes = ::decrypt_ciphertext_with_aad(aes_key.clone(), aes_nonce.clone(), encrypted_bytes.clone(), aad).unwrap(); + assert_eq!(file_bytes, decrypted_bytes); + + let tampered_aad = b"tampered aad".to_vec(); + let result = ::decrypt_ciphertext_with_aad(aes_key, aes_nonce, encrypted_bytes, tampered_aad); + assert!(result.is_err()); + } }