diff --git a/crates/notsecrets/src/decrypt.rs b/crates/notsecrets/src/decrypt.rs new file mode 100644 index 0000000..452789e --- /dev/null +++ b/crates/notsecrets/src/decrypt.rs @@ -0,0 +1,164 @@ +use crate::error::AgeError; +use crate::format::{header_bytes_up_to_footer, parse_header}; +use crate::identities::{FileKey, Identity}; +use chacha20poly1305::{ + aead::Aead, + ChaCha20Poly1305, Key, KeyInit, Nonce, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub struct Decryptor { + identities: Vec>, +} + +impl Decryptor { + pub fn with_identities(identities: Vec>) -> Self { + Self { identities } + } + + pub fn decrypt(&self, ciphertext: &[u8]) -> Result, AgeError> { + let (header, payload_offset) = parse_header(ciphertext)?; + + // Try to recover file key: try each identity against each stanza. + // `None` means the stanza type doesn't match; `Some(Err)` means the + // type matched but key material didn't — treat both as "keep trying". + let mut file_key: Option = None; + 'outer: for identity in &self.identities { + for stanza in &header.recipients { + if let Some(Ok(fk)) = identity.unwrap_file_key(stanza) { + file_key = Some(fk); + break 'outer; + } + } + } + let file_key = file_key.ok_or(AgeError::NoMatch)?; + + // Verify header MAC + let mac_key = derive_mac_key(&file_key)?; + let header_input = header_bytes_up_to_footer(ciphertext)?; + let mut mac = ::new_from_slice(&mac_key) + .map_err(|e| AgeError::CryptoError(format!("HMAC init: {e}")))?; + mac.update(&header_input); + mac.verify_slice(&header.mac) + .map_err(|_| AgeError::MacMismatch)?; + + // Decrypt payload + let payload = &ciphertext[payload_offset..]; + if payload.len() < 16 { + return Err(AgeError::ParseError( + "payload too short to contain nonce".to_string(), + )); + } + let nonce_bytes: [u8; 16] = payload[..16].try_into().expect("slice is exactly 16 bytes"); + let payload_ciphertext = &payload[16..]; + + let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); + let counter_nonce = Nonce::default(); + let plaintext = cipher + .decrypt(&counter_nonce, payload_ciphertext) + .map_err(|_| AgeError::CryptoError("payload decryption failed".to_string()))?; + + Ok(plaintext) + } +} + +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 crate::Encryptor; + use rand::rngs::OsRng; + use x25519_dalek::{PublicKey, StaticSecret}; + + fn make_x25519_pair() -> (X25519Identity, X25519Recipient) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + ( + X25519Identity::from_static_secret(secret), + X25519Recipient::from_public_key(public), + ) + } + + #[test] + fn decryptor_roundtrip_single_recipient() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let plaintext = b"the quick brown fox"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let decrypted = decryptor.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decryptor_roundtrip_multiple_recipients() { + let (identity1, recipient1) = make_x25519_pair(); + let (identity2, recipient2) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![ + Box::new(recipient1), + Box::new(recipient2), + ]) + .unwrap(); + let plaintext = b"multi-recipient test"; + let ciphertext = encryptor.encrypt(plaintext).unwrap(); + let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]); + assert_eq!(decryptor1.decrypt(&ciphertext).unwrap(), plaintext); + let decryptor2 = Decryptor::with_identities(vec![Box::new(identity2)]); + assert_eq!(decryptor2.decrypt(&ciphertext).unwrap(), plaintext); + } + + #[test] + fn decryptor_no_matching_identity_returns_no_match() { + let (_, recipient) = make_x25519_pair(); + let (wrong_identity, _) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let ciphertext = encryptor.encrypt(b"data").unwrap(); + let decryptor = Decryptor::with_identities(vec![Box::new(wrong_identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!(matches!(err, crate::error::AgeError::NoMatch)); + } + + #[test] + fn decryptor_corrupted_mac_returns_mac_mismatch() { + let (identity, recipient) = make_x25519_pair(); + let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap(); + let mut ciphertext = encryptor.encrypt(b"data").unwrap(); + // Flip a byte in the MAC region (after "--- ") + let footer_pos = ciphertext + .windows(4) + .position(|w| w == b"--- ") + .expect("footer present"); + ciphertext[footer_pos + 4] ^= 0xff; + let decryptor = Decryptor::with_identities(vec![Box::new(identity)]); + let err = decryptor.decrypt(&ciphertext).unwrap_err(); + assert!( + matches!( + err, + crate::error::AgeError::MacMismatch | crate::error::AgeError::ParseError(_) + ), + "expected MacMismatch or ParseError, got: {err:?}" + ); + } +} diff --git a/crates/notsecrets/src/identities/encrypted.rs b/crates/notsecrets/src/identities/encrypted.rs index 5e1ef7f..fb9c9c0 100644 --- a/crates/notsecrets/src/identities/encrypted.rs +++ b/crates/notsecrets/src/identities/encrypted.rs @@ -3,36 +3,53 @@ use crate::identities::{FileKey, Identity, Stanza}; /// An identity whose key material is stored in an age-encrypted file. /// -/// The outer file is decrypted on each call to `unwrap_file_key` using -/// `passphrase_identity`. The decrypted content must be an `AGE-SECRET-KEY-1...` -/// bech32 string, which is parsed as an `X25519Identity`. +/// The outer file is decrypted on each call to `unwrap_file_key` using a +/// scrypt passphrase identity. The decrypted content must be an +/// `AGE-SECRET-KEY-1...` bech32 string, which is parsed as an `X25519Identity`. pub struct EncryptedIdentity { /// Raw bytes of the age-encrypted identity file. encrypted_data: Vec, - /// Identity used to decrypt the outer age file (typically `ScryptIdentity`). - passphrase_identity: Box, + /// Passphrase used to decrypt the outer age file via `ScryptIdentity`. + passphrase: String, } impl EncryptedIdentity { - pub fn new(encrypted_data: Vec, passphrase_identity: Box) -> Self { + pub fn new(encrypted_data: Vec, passphrase: String) -> Self { Self { encrypted_data, - passphrase_identity, + passphrase, } } } impl Identity for EncryptedIdentity { - fn unwrap_file_key(&self, _stanza: &Stanza) -> Option> { - // Full implementation in Task 9 after Decryptor is available. - // The integration test below is marked #[ignore] until then. - let _ = &self.encrypted_data; - let _ = self.passphrase_identity.as_ref(); - Some(Err(AgeError::CryptoError( - "EncryptedIdentity requires Decryptor — implemented in Task 9".to_string(), - ))) + fn unwrap_file_key(&self, stanza: &Stanza) -> Option> { + let decryptor = crate::Decryptor::with_identities(vec![Box::new( + crate::identities::scrypt::ScryptIdentity::new(self.passphrase.clone()), + )]); + let plaintext = match decryptor.decrypt(&self.encrypted_data) { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let key_str = match std::str::from_utf8(&plaintext) { + Ok(s) => s.trim().to_string(), + Err(_) => { + return Some(Err(AgeError::ParseError( + "decrypted identity file is not valid UTF-8".to_string(), + ))) + } + }; + // Parse inner identity — only X25519 supported for now + let inner: Box = if key_str.starts_with("AGE-SECRET-KEY-1") { + match crate::identities::x25519::X25519Identity::from_bech32(&key_str) { + Ok(id) => Box::new(id), + Err(e) => return Some(Err(e)), + } + } else { + return Some(Err(AgeError::UnsupportedKeyType( + "encrypted identity file must contain AGE-SECRET-KEY-1".to_string(), + ))); + }; + inner.unwrap_file_key(stanza) } } - -// Integration test for EncryptedIdentity lives in tests/integration.rs and is -// activated in Task 9 once Encryptor and Decryptor are available. diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index ecf9384..b593ae7 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -1,3 +1,4 @@ +pub mod decrypt; pub mod encrypt; pub mod error; pub mod format; @@ -8,6 +9,7 @@ pub mod _legacy; pub use error::AgeError; pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity}; +pub use decrypt::Decryptor; pub use encrypt::Encryptor; pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient}; pub use sources::{BitwardenSource, FileSource, PromptSource};