feat(store): add PayloadCipher for AES-256-GCM and age attachment streaming encryption (GH-10) #39

Merged
KofTwentyTwo merged 1 commits from feature/GH-10-attachment-blob-encryption into main 2026-07-23 00:45:15 +00:00
5 changed files with 215 additions and 0 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/nuncio.iml" filepath="$PROJECT_DIR$/.idea/nuncio.iml" />
</modules>
</component>
</project>
+3
View File
@@ -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"
+3
View File
@@ -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]
+199
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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");
}
}
+2
View File
@@ -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};