diff --git a/README.md b/README.md index 1e3c02f..89f3c5a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ assert_eq!( ); ``` +### With AAD + +Context (e.g., a key name) can be authenticated alongside the message +with `encrypt_with_aad`/`decrypt_with_aad`. It is not stored in the +ciphertext, so decryption must be given the same context, and fails if +it does not match: + +```rust +use ecies::{decrypt_with_aad, encrypt_with_aad, utils::generate_keypair}; + +let (sk, pk) = generate_keypair(); +#[cfg(all(not(feature = "x25519"), not(feature = "ed25519")))] +let (sk, pk) = (&sk.serialize(), &pk.serialize()); +#[cfg(feature = "x25519")] +let (sk, pk) = (sk.as_bytes(), pk.as_bytes()); +#[cfg(feature = "ed25519")] +let (sk, pk) = (&sk, &pk); + +let encrypted = encrypt_with_aad(pk, b"msg", b"context").unwrap(); +assert_eq!( + b"msg", + decrypt_with_aad(sk, &encrypted, b"context").unwrap().as_slice() +); +assert!(decrypt_with_aad(sk, &encrypted, b"other").is_err()); +``` + ## Elliptic curve configuration ### Optional x25519/ed25519 support diff --git a/src/lib.rs b/src/lib.rs index 1f319d2..cccc6c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,9 +24,10 @@ mod sync; use config::{get_ephemeral_key_size, is_ephemeral_key_compressed, is_hkdf_key_compressed}; use elliptic::{decapsulate, encapsulate, generate_keypair, parse_pk, parse_sk, pk_to_vec, Error}; -use symmetric::{sym_decrypt, sym_encrypt}; +use symmetric::{sym_decrypt_with_aad, sym_encrypt_with_aad}; use crate::compat::Vec; +use crate::consts::EMPTY_BYTES; pub use elliptic::{PublicKey, SecretKey}; /// Encrypt a message by a public key @@ -36,11 +37,27 @@ pub use elliptic::{PublicKey, SecretKey}; /// * `receiver_pub` - The u8 array reference of a receiver's public key /// * `msg` - The u8 array reference of the message to encrypt pub fn encrypt(receiver_pub: &[u8], msg: &[u8]) -> Result, Error> { + encrypt_with_aad(receiver_pub, msg, &EMPTY_BYTES) +} + +/// Encrypt a message by a public key, with additional authenticated data +/// (AAD). +/// +/// The AAD is authenticated but not encrypted, and is not stored in the +/// ciphertext; the same AAD must be given to [`decrypt_with_aad`]. An +/// empty AAD produces output identical to [`encrypt`]. +/// +/// # Arguments +/// +/// * `receiver_pub` - The u8 array reference of a receiver's public key +/// * `msg` - The u8 array reference of the message to encrypt +/// * `aad` - The u8 array reference of the additional authenticated data +pub fn encrypt_with_aad(receiver_pub: &[u8], msg: &[u8], aad: &[u8]) -> Result, Error> { let receiver_pk = parse_pk(receiver_pub)?; let (ephemeral_sk, ephemeral_pk) = generate_keypair(); let sym_key = encapsulate(&ephemeral_sk, &receiver_pk, is_hkdf_key_compressed())?; - let encrypted = sym_encrypt(&sym_key, msg).ok_or(Error::InvalidMessage)?; + let encrypted = sym_encrypt_with_aad(&sym_key, msg, aad).ok_or(Error::InvalidMessage)?; let is_compressed = is_ephemeral_key_compressed(); let key_size = get_ephemeral_key_size(); @@ -61,6 +78,21 @@ pub fn encrypt(receiver_pub: &[u8], msg: &[u8]) -> Result, Error> { /// * `receiver_sec` - The u8 array reference of a receiver's secret key /// * `msg` - The u8 array reference of the encrypted message pub fn decrypt(receiver_sec: &[u8], msg: &[u8]) -> Result, Error> { + decrypt_with_aad(receiver_sec, msg, &EMPTY_BYTES) +} + +/// Decrypt a message by a secret key, with additional authenticated data +/// (AAD). +/// +/// Authentication fails with [`Error::InvalidMessage`] if the AAD does +/// not match the one given to [`encrypt_with_aad`]. +/// +/// # Arguments +/// +/// * `receiver_sec` - The u8 array reference of a receiver's secret key +/// * `msg` - The u8 array reference of the encrypted message +/// * `aad` - The u8 array reference of the additional authenticated data +pub fn decrypt_with_aad(receiver_sec: &[u8], msg: &[u8], aad: &[u8]) -> Result, Error> { let receiver_sk = parse_sk(receiver_sec)?; let key_size = get_ephemeral_key_size(); @@ -72,5 +104,5 @@ pub fn decrypt(receiver_sec: &[u8], msg: &[u8]) -> Result, Error> { let encrypted = &msg[key_size..]; let sym_key = decapsulate(&ephemeral_pk, &receiver_sk, is_hkdf_key_compressed())?; - sym_decrypt(&sym_key, encrypted).ok_or(Error::InvalidMessage) + sym_decrypt_with_aad(&sym_key, encrypted, aad).ok_or(Error::InvalidMessage) } diff --git a/src/symmetric/aead.rs b/src/symmetric/aead.rs index 6a35f5b..d2ddf5f 100644 --- a/src/symmetric/aead.rs +++ b/src/symmetric/aead.rs @@ -23,6 +23,16 @@ use crate::consts::{AEAD_TAG_LENGTH, EMPTY_BYTES, NONCE_LENGTH, NONCE_TAG_LENGTH /// /// It's basically safe to just `unwrap` the returned `Option>`. pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { + encrypt_with_aad(key, nonce, msg, &EMPTY_BYTES) +} + +/// Pure Rust AES-256-GCM or XChaCha20-Poly1305 encryption wrapper with +/// additional authenticated data (AAD). +/// +/// The AAD is authenticated but not encrypted, and is not stored in the +/// output; the same AAD must be given to [`decrypt_with_aad`]. An empty +/// AAD is equivalent to [`encrypt`]. +pub fn encrypt_with_aad(key: &[u8], nonce: &[u8], msg: &[u8], aad: &[u8]) -> Option> { let key = GenericArray::from_slice(key); let aead = Cipher::new(key); @@ -32,7 +42,7 @@ pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { output.extend(msg); let nonce = GenericArray::from_slice(nonce); - aead.encrypt_in_place_detached(nonce, &EMPTY_BYTES, &mut output[NONCE_TAG_LENGTH..]) + aead.encrypt_in_place_detached(nonce, aad, &mut output[NONCE_TAG_LENGTH..]) .map(|tag| { output[NONCE_LENGTH..NONCE_TAG_LENGTH].copy_from_slice(tag.as_slice()); output @@ -42,6 +52,15 @@ pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { /// Pure Rust AES-256-GCM or XChaCha20-Poly1305 decryption wrapper pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { + decrypt_with_aad(key, encrypted, &EMPTY_BYTES) +} + +/// Pure Rust AES-256-GCM or XChaCha20-Poly1305 decryption wrapper with +/// additional authenticated data (AAD). +/// +/// Authentication fails if the AAD does not match the one given to +/// [`encrypt_with_aad`]. +pub fn decrypt_with_aad(key: &[u8], encrypted: &[u8], aad: &[u8]) -> Option> { if encrypted.len() < NONCE_TAG_LENGTH { return None; } @@ -54,7 +73,7 @@ pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { let mut out = Vec::with_capacity(encrypted.len() - NONCE_TAG_LENGTH); out.extend(&encrypted[NONCE_TAG_LENGTH..]); - aead.decrypt_in_place_detached(nonce, &EMPTY_BYTES, &mut out, tag) + aead.decrypt_in_place_detached(nonce, aad, &mut out, tag) .map(|_| out) .ok() } diff --git a/src/symmetric/mod.rs b/src/symmetric/mod.rs index ac098c7..0a07934 100644 --- a/src/symmetric/mod.rs +++ b/src/symmetric/mod.rs @@ -6,12 +6,12 @@ use crate::consts::NONCE_LENGTH; #[cfg(any(feature = "aes-rust", feature = "xchacha20"))] mod aead; #[cfg(any(feature = "aes-rust", feature = "xchacha20"))] -use aead::{decrypt, encrypt}; +use aead::{decrypt, decrypt_with_aad, encrypt, encrypt_with_aad}; #[cfg(feature = "aes-openssl")] mod openssl_aes; #[cfg(feature = "aes-openssl")] -use openssl_aes::{decrypt, encrypt}; +use openssl_aes::{decrypt, decrypt_with_aad, encrypt, encrypt_with_aad}; mod hash; @@ -28,16 +28,36 @@ pub fn sym_encrypt(key: &[u8], msg: &[u8]) -> Option> { encrypt(key, &nonce, msg) } +/// Symmetric encryption wrapper with additional authenticated data (AAD). +/// +/// The AAD is authenticated but not encrypted, and is not stored in the +/// output; the same AAD must be given to [`sym_decrypt_with_aad`]. An +/// empty AAD is equivalent to [`sym_encrypt`]. +pub fn sym_encrypt_with_aad(key: &[u8], msg: &[u8], aad: &[u8]) -> Option> { + let mut nonce = [0u8; NONCE_LENGTH]; + OsRng.fill_bytes(&mut nonce); + encrypt_with_aad(key, &nonce, msg, aad) +} + /// Symmetric decryption wrapper pub fn sym_decrypt(key: &[u8], encrypted: &[u8]) -> Option> { decrypt(key, encrypted) } +/// Symmetric decryption wrapper with additional authenticated data +/// (AAD). +/// +/// Authentication fails if the AAD does not match the one given to +/// [`sym_encrypt_with_aad`]. +pub fn sym_decrypt_with_aad(key: &[u8], encrypted: &[u8], aad: &[u8]) -> Option> { + decrypt_with_aad(key, encrypted, aad) +} + #[cfg(test)] mod tests { use super::*; use crate::{ - consts::{NONCE_TAG_LENGTH, ZERO_SECRET}, + consts::{EMPTY_BYTES, NONCE_TAG_LENGTH, ZERO_SECRET}, utils::tests::decode_hex, }; @@ -60,6 +80,56 @@ mod tests { } } + #[test] + pub(super) fn test_aad() { + let mut key = ZERO_SECRET; + OsRng.fill_bytes(&mut key); + let msg = b"bound to this context"; + + let encrypted = sym_encrypt_with_aad(&key, msg, b"entry-name").unwrap(); + // Correct AAD decrypts. + assert_eq!( + msg.to_vec(), + sym_decrypt_with_aad(&key, &encrypted, b"entry-name").unwrap() + ); + // Wrong or missing AAD fails authentication. + assert!(sym_decrypt_with_aad(&key, &encrypted, b"other-name").is_none()); + assert!(sym_decrypt(&key, &encrypted).is_none()); + + // Empty AAD is equivalent to the legacy wrappers. + let legacy = sym_encrypt(&key, msg).unwrap(); + assert_eq!(msg.to_vec(), sym_decrypt_with_aad(&key, &legacy, &EMPTY_BYTES).unwrap()); + assert_eq!(msg.to_vec(), sym_decrypt(&key, &legacy).unwrap()); + } + + #[test] + #[cfg(all(feature = "aes-rust", not(feature = "aes-short-nonce"), not(feature = "xchacha20")))] + pub(super) fn test_aes_aad_layout() { + // Verify the wire layout (nonce || tag || ct) and the AAD + // placement against a direct aes-gcm call. + use aes_gcm::aead::{generic_array::GenericArray, AeadInPlace}; + use aes_gcm::{aes::Aes256, AesGcm, KeyInit}; + + type Cipher = AesGcm; + + let key = [7u8; 32]; + let nonce = [3u8; NONCE_LENGTH]; + let aad = b"entry-name"; + let msg = b"bound plaintext"; + + // Reference: ct and tag computed by the aes-gcm crate directly, + // bypassing our wrapper entirely. + let mut ref_ct = msg.to_vec(); + let ref_tag = Cipher::new(GenericArray::from_slice(&key)) + .encrypt_in_place_detached(GenericArray::from_slice(&nonce), aad, &mut ref_ct) + .unwrap(); + + let ours = encrypt_with_aad(&key, &nonce, msg, aad).unwrap(); + assert_eq!(&ours[..NONCE_LENGTH], &nonce); + assert_eq!(&ours[NONCE_LENGTH..NONCE_TAG_LENGTH], ref_tag.as_slice()); + assert_eq!(&ours[NONCE_TAG_LENGTH..], ref_ct.as_slice()); + } + #[test] #[cfg(all(not(feature = "aes-short-nonce"), not(feature = "xchacha20")))] pub(super) fn test_aes_known_key() { diff --git a/src/symmetric/openssl_aes.rs b/src/symmetric/openssl_aes.rs index ac62f8d..95e58b2 100644 --- a/src/symmetric/openssl_aes.rs +++ b/src/symmetric/openssl_aes.rs @@ -5,6 +5,16 @@ use crate::Vec; /// AES-256-GCM encryption wrapper pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { + encrypt_with_aad(key, nonce, msg, &EMPTY_BYTES) +} + +/// AES-256-GCM encryption wrapper with additional authenticated data +/// (AAD). +/// +/// The AAD is authenticated but not encrypted, and is not stored in the +/// output; the same AAD must be given to [`decrypt_with_aad`]. An empty +/// AAD is equivalent to [`encrypt`]. +pub fn encrypt_with_aad(key: &[u8], nonce: &[u8], msg: &[u8], aad: &[u8]) -> Option> { let cipher = Cipher::aes_256_gcm(); let mut output = Vec::with_capacity(NONCE_TAG_LENGTH + msg.len()); @@ -12,7 +22,7 @@ pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { output.extend([0u8; AEAD_TAG_LENGTH]); let tag = &mut output[NONCE_LENGTH..NONCE_TAG_LENGTH]; - encrypt_aead(cipher, key, Some(nonce), &EMPTY_BYTES, msg, tag) + encrypt_aead(cipher, key, Some(nonce), aad, msg, tag) .map(|encrypted| { output.extend(encrypted); output @@ -22,6 +32,15 @@ pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { /// AES-256-GCM decryption wrapper pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { + decrypt_with_aad(key, encrypted, &EMPTY_BYTES) +} + +/// AES-256-GCM decryption wrapper with additional authenticated data +/// (AAD). +/// +/// Authentication fails if the AAD does not match the one given to +/// [`encrypt_with_aad`]. +pub fn decrypt_with_aad(key: &[u8], encrypted: &[u8], aad: &[u8]) -> Option> { if encrypted.len() < NONCE_TAG_LENGTH { return None; } @@ -31,5 +50,5 @@ pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { let nonce = &encrypted[..NONCE_LENGTH]; let tag = &encrypted[NONCE_LENGTH..NONCE_TAG_LENGTH]; let encrypted = &encrypted[NONCE_TAG_LENGTH..]; - decrypt_aead(cipher, key, Some(nonce), &EMPTY_BYTES, encrypted, tag).ok() + decrypt_aead(cipher, key, Some(nonce), aad, encrypted, tag).ok() } diff --git a/tests/integration.rs b/tests/integration.rs index c5e7ede..54d2d1c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -54,3 +54,29 @@ fn is_compatible_with_python() { assert_eq!(res.as_bytes(), MSG.as_bytes()); } + +#[test] +#[cfg(all(not(target_arch = "wasm32"), not(feature = "x25519"), not(feature = "ed25519"),))] +fn test_aad() { + use ecies::utils::generate_keypair; + use ecies::{decrypt, decrypt_with_aad, encrypt, encrypt_with_aad}; + let (sk, pk) = generate_keypair(); + let (sk, pk) = (sk.serialize(), pk.serialize()); + + const MSG: &[u8] = b"hello aad"; + const AAD: &[u8] = b"entry:DATABASE_PASSWORD"; + + // Correct AAD round-trips. + let ct = encrypt_with_aad(&pk, MSG, AAD).unwrap(); + assert_eq!(decrypt_with_aad(&sk, &ct, AAD).unwrap(), MSG); + + // Wrong or missing AAD fails closed. + assert!(decrypt_with_aad(&sk, &ct, b"entry:API_TOKEN").is_err()); + assert!(decrypt(&sk, &ct).is_err()); + + // Empty AAD is equivalent to the legacy API in both directions. + let legacy = encrypt(&pk, MSG).unwrap(); + assert_eq!(decrypt_with_aad(&sk, &legacy, b"").unwrap(), MSG); + assert_eq!(decrypt(&sk, &legacy).unwrap(), MSG); + assert_eq!(encrypt_with_aad(&pk, MSG, b"").unwrap().len(), legacy.len()); +}