diff --git a/services/comm-services-lib/Cargo.lock b/services/comm-services-lib/Cargo.lock --- a/services/comm-services-lib/Cargo.lock +++ b/services/comm-services-lib/Cargo.lock @@ -266,6 +266,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.6" @@ -786,6 +822,16 @@ "winapi", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -804,6 +850,8 @@ "actix-multipart", "actix-web", "actix-web-httpauth", + "aead", + "aes-gcm", "anyhow", "aws-config", "aws-sdk-dynamodb", @@ -882,9 +930,19 @@ checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cxx" version = "1.0.91" @@ -1155,6 +1213,16 @@ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.28.0" @@ -1343,6 +1411,15 @@ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1568,6 +1645,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "openssl" version = "0.10.56" @@ -1697,6 +1780,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -2365,6 +2460,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" diff --git a/services/comm-services-lib/Cargo.toml b/services/comm-services-lib/Cargo.toml --- a/services/comm-services-lib/Cargo.toml +++ b/services/comm-services-lib/Cargo.toml @@ -21,6 +21,7 @@ "dep:tokio-stream", "dep:actix-web-httpauth", ] +crypto = ["dep:aead", "dep:aes-gcm", "dep:bytes"] [dependencies] serde = { version = "1.0", features = ["derive"] } @@ -51,3 +52,6 @@ actix-web-httpauth = { version = "0.8.0", optional = true } actix-multipart = { version = "0.6", optional = true } tokio-stream = { version = "0.1.14", optional = true } +# crypto dependencies +aes-gcm = { version = "0.10", optional = true } +aead = { version = "0.5", features = ["bytes"], optional = true } diff --git a/services/comm-services-lib/src/crypto/aes256.rs b/services/comm-services-lib/src/crypto/aes256.rs new file mode 100644 --- /dev/null +++ b/services/comm-services-lib/src/crypto/aes256.rs @@ -0,0 +1,122 @@ +use aead::{ + generic_array::GenericArray, Aead, AeadCore, AeadInPlace, KeyInit, OsRng, +}; +use aes_gcm::Aes256Gcm; +use aws_sdk_dynamodb::types::AttributeValue; +use bytes::BytesMut; + +use crate::database::{DBItemError, TryFromAttribute}; + +pub use aes_gcm::Error as AES256Error; + +const TAG_LEN: usize = 16; +const NONCE_LEN: usize = 12; + +#[derive(Clone, Debug, derive_more::From, derive_more::AsRef)] +pub struct EncryptionKey(aes_gcm::Key); + +impl EncryptionKey { + /// Generates a new AES256 key. + pub fn new() -> EncryptionKey { + Aes256Gcm::generate_key(&mut OsRng).into() + } +} + +impl Default for EncryptionKey { + fn default() -> Self { + Self::new() + } +} + +// database conversions +impl From for AttributeValue { + fn from(key: EncryptionKey) -> Self { + use aws_sdk_dynamodb::primitives::Blob; + AttributeValue::B(Blob::new(key.0.to_vec())) + } +} +impl TryFromAttribute for EncryptionKey { + fn try_from_attr( + attribute_name: impl Into, + attribute: Option, + ) -> Result { + let bytes = Vec::::try_from_attr(attribute_name, attribute)?; + let key = aes_gcm::Key::::from_slice(&bytes); + Ok(Self(*key)) + } +} + +/// Encrypts a plaintext with the given key. Returns the sealed data +/// in the following format: nonce || ciphertext || tag +pub fn encrypt( + plaintext: &[u8], + key: &EncryptionKey, +) -> Result, AES256Error> { + let cipher = Aes256Gcm::new(key.as_ref()); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + + // Construct an output buffer: nonce || ciphertext || tag + // Then split it into nonce and ciphertext || tag, + // encrypt and concatenate again. + // This is done on contiguous memory to avoid extra allocations. + + let mut output = + BytesMut::with_capacity(nonce.len() + plaintext.len() + TAG_LEN); + output.extend_from_slice(&nonce); + output.extend_from_slice(plaintext); + + let mut buffer = output.split_off(nonce.len()); + cipher.encrypt_in_place(&nonce, b"", &mut buffer)?; + output.unsplit(buffer); + + Ok(output.into()) +} + +/// Decrypts a ciphertext with the given key. Expects the sealed data +/// in the following format: nonce || ciphertext || tag +pub fn decrypt( + ciphertext: &[u8], + key: &EncryptionKey, +) -> Result, AES256Error> { + if ciphertext.len() < NONCE_LEN + TAG_LEN { + return Err(AES256Error); + } + let cipher = Aes256Gcm::new(key.as_ref()); + let nonce = GenericArray::from_slice(&ciphertext[..NONCE_LEN]); + cipher.decrypt(nonce, &ciphertext[NONCE_LEN..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aes256() { + let key = EncryptionKey::new(); + let plaintext = b"hello world"; + let ciphertext = encrypt(plaintext, &key).expect("Encrypt failed"); + let decrypted = decrypt(&ciphertext, &key).expect("Decrypt failed"); + assert_eq!(plaintext, &decrypted[..]); + } + + #[test] + fn test_aes256_invalid_key() { + let key = EncryptionKey::new(); + let plaintext = b"hello world"; + let ciphertext = encrypt(plaintext, &key).expect("Encrypt failed"); + + let other_key = EncryptionKey::new(); + decrypt(&ciphertext, &other_key).expect_err("Decrypt should fail"); + } + + #[test] + fn test_aes256_malfolmed() { + let key = EncryptionKey::new(); + let plaintext = b"hello world"; + let mut ciphertext = encrypt(plaintext, &key).expect("Encrypt failed"); + + ciphertext[0] ^= 0xaa; // do the flip + + decrypt(&ciphertext, &key).expect_err("Decrypt should fail"); + } +} diff --git a/services/comm-services-lib/src/crypto/mod.rs b/services/comm-services-lib/src/crypto/mod.rs new file mode 100644 --- /dev/null +++ b/services/comm-services-lib/src/crypto/mod.rs @@ -0,0 +1,2 @@ +/// AES 256 GCM encryption and decryption. +pub mod aes256; diff --git a/services/comm-services-lib/src/lib.rs b/services/comm-services-lib/src/lib.rs --- a/services/comm-services-lib/src/lib.rs +++ b/services/comm-services-lib/src/lib.rs @@ -2,6 +2,8 @@ pub mod backup; pub mod blob; pub mod constants; +#[cfg(feature = "crypto")] +pub mod crypto; pub mod database; #[cfg(feature = "http")] pub mod http;