From 9863af6b4574eec2b32425ca90328c42b127602e Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:02:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(notsecrets):=20Encryptor=20=E2=80=94=20mul?= =?UTF-8?q?ti-recipient=20age=20encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/notsecrets/src/encrypt.rs | 159 +++++++++++++++++++++++++++++++ crates/notsecrets/src/lib.rs | 2 + 2 files changed, 161 insertions(+) create mode 100644 crates/notsecrets/src/encrypt.rs diff --git a/crates/notsecrets/src/encrypt.rs b/crates/notsecrets/src/encrypt.rs new file mode 100644 index 0000000..a3c53fe --- /dev/null +++ b/crates/notsecrets/src/encrypt.rs @@ -0,0 +1,159 @@ +use crate::error::AgeError; +use crate::format::serialize_header; +use crate::identities::{FileKey, Header}; +use crate::recipients::Recipient; +use chacha20poly1305::{ + aead::Aead, + ChaCha20Poly1305, Key, KeyInit, Nonce, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Encryptor { + recipients: Vec>, +} + +impl Encryptor { + /// Construct an Encryptor. Returns AgeError if: + /// - recipients is empty + /// - more than one scrypt recipient is present + pub fn with_recipients(recipients: Vec>) -> Result { + if recipients.is_empty() { + return Err(AgeError::CryptoError( + "at least one recipient is required".to_string(), + )); + } + // Detect scrypt recipients by wrapping a dummy key and checking stanza tag + let dummy_key = FileKey::new([0u8; 16]); + let scrypt_count = recipients + .iter() + .filter_map(|r| r.wrap_file_key(&dummy_key).ok()) + .filter(|s| s.tag == "scrypt") + .count(); + if scrypt_count > 1 { + return Err(AgeError::CryptoError( + "at most one scrypt recipient is allowed per age file".to_string(), + )); + } + Ok(Self { recipients }) + } + + pub fn encrypt(&self, plaintext: &[u8]) -> Result, AgeError> { + let file_key = FileKey::generate(); + + // Wrap file key with each recipient + let stanzas: Vec<_> = self + .recipients + .iter() + .map(|r| r.wrap_file_key(&file_key)) + .collect::>()?; + + // Build header without MAC first + let header_no_mac = Header { + recipients: stanzas, + mac: Vec::new(), + }; + + // Serialize and compute MAC: HMAC-SHA256 over bytes up to and including "--- " + let header_bytes = serialize_header(&header_no_mac); + let mac_key = derive_mac_key(&file_key)?; + let footer_pos = header_bytes + .windows(4) + .rposition(|w| w == b"--- ") + .ok_or_else(|| AgeError::ParseError("serialize_header missing footer".to_string()))?; + let mac_input = &header_bytes[..footer_pos + 4]; + + let mut mac = ::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(mac_input); + let mac_bytes = mac.finalize().into_bytes().to_vec(); + + // Re-serialize with actual MAC + let header_with_mac = Header { + recipients: header_no_mac.recipients, + mac: mac_bytes, + }; + let header_bytes = serialize_header(&header_with_mac); + + // Payload: 16-byte random nonce + ChaCha20Poly1305(payload_key, plaintext) + let mut nonce_bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); // [0u8; 12] + let ciphertext = cipher + .encrypt(&counter_nonce, plaintext) + .map_err(|_| AgeError::CryptoError("payload encryption failed".to_string()))?; + + let mut output = header_bytes; + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + Ok(output) + } +} + +fn derive_mac_key(file_key: &FileKey) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(b""), file_key.as_bytes()); + let mut mac_key = [0u8; 32]; + hk.expand(b"header", &mut mac_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF mac key: {e}")))?; + Ok(mac_key) +} + +fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32], AgeError> { + let hk = Hkdf::::new(Some(nonce.as_slice()), file_key.as_bytes()); + let mut payload_key = [0u8; 32]; + hk.expand(b"payload", &mut payload_key) + .map_err(|e| AgeError::CryptoError(format!("HKDF payload key: {e}")))?; + Ok(payload_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identities::x25519::X25519Identity; + use crate::recipients::x25519::X25519Recipient; + use rand::rngs::OsRng; + use x25519_dalek::{PublicKey, StaticSecret}; + + #[test] + fn encryptor_single_x25519_recipient() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + let recipient: Box = + Box::new(X25519Recipient::from_public_key(public)); + let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap(); + let plaintext = b"hello, age!"; + let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed"); + assert!( + ciphertext.starts_with(b"age-encryption.org/v1\n"), + "output must start with age version line" + ); + } + + #[test] + fn encryptor_rejects_multiple_scrypt_recipients() { + use crate::recipients::scrypt::ScryptRecipient; + let r1: Box = + Box::new(ScryptRecipient::new("pass1".to_string(), 14)); + let r2: Box = + Box::new(ScryptRecipient::new("pass2".to_string(), 14)); + let result = Encryptor::with_recipients(vec![r1, r2]); + assert!(result.is_err(), "two scrypt recipients must be rejected"); + } + + #[test] + fn encryptor_empty_recipients_returns_error() { + let result = Encryptor::with_recipients(vec![]); + assert!(result.is_err(), "empty recipients must be rejected"); + } + + // Suppress unused import warning for X25519Identity which may be used in Task 9 + #[allow(dead_code)] + fn _use_x25519_identity(_: X25519Identity) {} +} diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index d917e1c..ecf9384 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -1,3 +1,4 @@ +pub mod encrypt; pub mod error; pub mod format; pub mod identities; @@ -7,6 +8,7 @@ pub mod _legacy; pub use error::AgeError; pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity}; +pub use encrypt::Encryptor; pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient}; pub use sources::{BitwardenSource, FileSource, PromptSource};