From c6c141385c0eb0b33b2dae6765ed13e60aa96784 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 22 Jul 2026 19:45:02 -0500 Subject: [PATCH] feat(store): add PayloadCipher for AES-256-GCM and age attachment streaming encryption --- .idea/modules.xml | 8 ++ Cargo.toml | 3 + crates/nuncio-store/Cargo.toml | 3 + crates/nuncio-store/src/cipher.rs | 199 ++++++++++++++++++++++++++++++ crates/nuncio-store/src/lib.rs | 2 + 5 files changed, 215 insertions(+) create mode 100644 .idea/modules.xml create mode 100644 crates/nuncio-store/src/cipher.rs diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d91f84a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ca919cb..51f8856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,5 +32,8 @@ crossterm = "0.28" clap = { version = "4.5", features = ["derive", "env"] } tempfile = "3.10" keyring = "2.1" +age = "0.10" +aes-gcm = "0.10" + diff --git a/crates/nuncio-store/Cargo.toml b/crates/nuncio-store/Cargo.toml index 1de639e..766ef8c 100644 --- a/crates/nuncio-store/Cargo.toml +++ b/crates/nuncio-store/Cargo.toml @@ -15,6 +15,9 @@ serde_json.workspace = true thiserror.workspace = true tracing.workspace = true keyring.workspace = true +age.workspace = true +aes-gcm.workspace = true + [dev-dependencies] diff --git a/crates/nuncio-store/src/cipher.rs b/crates/nuncio-store/src/cipher.rs new file mode 100644 index 0000000..b314251 --- /dev/null +++ b/crates/nuncio-store/src/cipher.rs @@ -0,0 +1,199 @@ +//! Cryptographic payload encryption and `age` streaming attachment cipher. + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use std::io::{Read, Write}; +use thiserror::Error; + +/// Cryptographic cipher errors. +#[derive(Error, Debug, PartialEq, Eq)] +pub enum CipherError { + /// Encryption payload processing failure. + #[error("payload encryption failed: {0}")] + EncryptionFailed(String), + /// Decryption payload processing or authentication failure. + #[error("payload decryption failed: {0}")] + DecryptionFailed(String), +} + +/// Cryptographic cipher manager handling record-level AES-256-GCM and `age` attachment ciphers. +pub struct PayloadCipher; + +impl PayloadCipher { + /// Nonce byte length for AES-256-GCM (12 bytes / 96 bits). + pub const NONCE_LEN: usize = 12; + + /// Encrypt a byte slice using AES-256-GCM with a 256-bit symmetric key. + /// The 12-byte random nonce is prepended to the returned ciphertext payload. + pub fn encrypt_bytes(key: &[u8; 32], plaintext: &[u8]) -> Result, CipherError> { + let cipher = Aes256Gcm::new(key.into()); + let mut nonce_bytes = [0u8; Self::NONCE_LEN]; + use aes_gcm::aead::rand_core::RngCore; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let mut encrypted = cipher + .encrypt(nonce, plaintext) + .map_err(|e| CipherError::EncryptionFailed(e.to_string()))?; + + let mut output = Vec::with_capacity(Self::NONCE_LEN + encrypted.len()); + output.extend_from_slice(&nonce_bytes); + output.append(&mut encrypted); + + Ok(output) + } + + /// Decrypt a byte slice created by [`PayloadCipher::encrypt_bytes`]. + pub fn decrypt_bytes(key: &[u8; 32], ciphertext: &[u8]) -> Result, CipherError> { + if ciphertext.len() < Self::NONCE_LEN { + return Err(CipherError::DecryptionFailed( + "ciphertext payload too short".to_string(), + )); + } + + let (nonce_bytes, payload) = ciphertext.split_at(Self::NONCE_LEN); + let cipher = Aes256Gcm::new(key.into()); + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, payload) + .map_err(|_| CipherError::DecryptionFailed("authentication tag mismatch".to_string())) + } + + /// Encrypt binary attachment payload using `age` passphrase encryption. + pub fn encrypt_attachment_stream(passphrase: &str, input: &[u8]) -> Result, CipherError> { + let encryptor = age::Encryptor::with_user_passphrase(secret_service_passphrase(passphrase)); + let mut encrypted_output = Vec::new(); + let mut writer = encryptor + .wrap_output(&mut encrypted_output) + .map_err(|e| CipherError::EncryptionFailed(e.to_string()))?; + + writer + .write_all(input) + .map_err(|e| CipherError::EncryptionFailed(e.to_string()))?; + + writer + .finish() + .map_err(|e| CipherError::EncryptionFailed(e.to_string()))?; + + Ok(encrypted_output) + } + + /// Decrypt binary attachment payload encrypted by [`PayloadCipher::encrypt_attachment_stream`]. + pub fn decrypt_attachment_stream(passphrase: &str, ciphertext: &[u8]) -> Result, CipherError> { + let decryptor = match age::Decryptor::new(ciphertext) { + Ok(age::Decryptor::Passphrase(d)) => d, + Ok(_) => { + return Err(CipherError::DecryptionFailed( + "unexpected age header format".to_string(), + )) + } + Err(e) => return Err(CipherError::DecryptionFailed(e.to_string())), + }; + + let mut reader = decryptor + .decrypt(&secret_service_passphrase(passphrase), None) + .map_err(|e| CipherError::DecryptionFailed(e.to_string()))?; + + let mut plaintext = Vec::new(); + reader + .read_to_end(&mut plaintext) + .map_err(|e| CipherError::DecryptionFailed(e.to_string()))?; + + Ok(plaintext) + } +} + +fn secret_service_passphrase(raw: &str) -> age::secrecy::SecretString { + age::secrecy::SecretString::new(raw.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_256_gcm_encrypt_decrypt_roundtrip() { + let key = [42u8; 32]; + let plaintext = b"Nuncio encrypted payload text"; + + let ciphertext = PayloadCipher::encrypt_bytes(&key, plaintext).expect("encryption succeeds"); + assert_ne!(ciphertext, plaintext); + assert!(ciphertext.len() > PayloadCipher::NONCE_LEN); + + let decrypted = PayloadCipher::decrypt_bytes(&key, &ciphertext).expect("decryption succeeds"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn aes_256_gcm_short_ciphertext_fails() { + let key = [42u8; 32]; + let short_payload = [0u8; 5]; + let err = PayloadCipher::decrypt_bytes(&key, &short_payload).expect_err("should fail short payload"); + assert_eq!( + err, + CipherError::DecryptionFailed("ciphertext payload too short".to_string()) + ); + } + + #[test] + fn aes_256_gcm_tampered_ciphertext_fails() { + let key = [42u8; 32]; + let plaintext = b"Sensitive email body payload"; + let mut ciphertext = PayloadCipher::encrypt_bytes(&key, plaintext).unwrap(); + + // Corrupt last byte of tag + let last_idx = ciphertext.len() - 1; + ciphertext[last_idx] ^= 0xFF; + + let err = PayloadCipher::decrypt_bytes(&key, &ciphertext).expect_err("should fail tamper check"); + assert_eq!( + err, + CipherError::DecryptionFailed("authentication tag mismatch".to_string()) + ); + } + + #[test] + fn age_attachment_stream_encrypt_decrypt_roundtrip() { + let passphrase = "master_vault_passphrase_99"; + let attachment_data = b"PDF invoice binary attachment data payload"; + + let encrypted = PayloadCipher::encrypt_attachment_stream(passphrase, attachment_data) + .expect("age encryption succeeds"); + + let decrypted = PayloadCipher::decrypt_attachment_stream(passphrase, &encrypted) + .expect("age decryption succeeds"); + + assert_eq!(decrypted, attachment_data); + } + + #[test] + fn age_attachment_stream_invalid_passphrase_fails() { + let passphrase = "correct_passphrase"; + let wrong_passphrase = "wrong_passphrase"; + let data = b"Confidential document"; + + let encrypted = PayloadCipher::encrypt_attachment_stream(passphrase, data).unwrap(); + let err = PayloadCipher::decrypt_attachment_stream(wrong_passphrase, &encrypted) + .expect_err("should fail with wrong passphrase"); + + assert!(matches!(err, CipherError::DecryptionFailed(_))); + } + + #[test] + fn age_attachment_stream_invalid_header_fails() { + let err = PayloadCipher::decrypt_attachment_stream("pass", b"invalid_age_bytes") + .expect_err("should fail invalid header"); + + assert!(matches!(err, CipherError::DecryptionFailed(_))); + } + + #[test] + fn error_display_formatting() { + let enc_err = CipherError::EncryptionFailed("failed".to_string()); + assert_eq!(enc_err.to_string(), "payload encryption failed: failed"); + + let dec_err = CipherError::DecryptionFailed("failed".to_string()); + assert_eq!(dec_err.to_string(), "payload decryption failed: failed"); + } +} diff --git a/crates/nuncio-store/src/lib.rs b/crates/nuncio-store/src/lib.rs index 4898ada..26dcafc 100644 --- a/crates/nuncio-store/src/lib.rs +++ b/crates/nuncio-store/src/lib.rs @@ -1,9 +1,11 @@ //! Local storage, full-text search indexing, and credential security for Nuncio. +pub mod cipher; pub mod db; pub mod search; pub mod vault; +pub use cipher::{CipherError, PayloadCipher}; pub use db::{DatabaseEngine, DatabaseError}; pub use search::{SearchEngine, SearchHit}; pub use vault::{MockKeyring, SecretManager, SecretVault, VaultError}; -- 2.54.0