feat(notsecrets): Decryptor — identity-driven age decryption with MAC verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph O'Brien
2026-04-01 21:06:55 -04:00
parent 9863af6b45
commit e388d75cf1
3 changed files with 201 additions and 18 deletions

View File

@@ -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<Sha256>;
pub struct Decryptor {
identities: Vec<Box<dyn Identity>>,
}
impl Decryptor {
pub fn with_identities(identities: Vec<Box<dyn Identity>>) -> Self {
Self { identities }
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, 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<FileKey> = 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 = <HmacSha256 as 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::<Sha256>::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::<Sha256>::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:?}"
);
}
}

View File

@@ -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<u8>,
/// Identity used to decrypt the outer age file (typically `ScryptIdentity`).
passphrase_identity: Box<dyn Identity>,
/// Passphrase used to decrypt the outer age file via `ScryptIdentity`.
passphrase: String,
}
impl EncryptedIdentity {
pub fn new(encrypted_data: Vec<u8>, passphrase_identity: Box<dyn Identity>) -> Self {
pub fn new(encrypted_data: Vec<u8>, passphrase: String) -> Self {
Self {
encrypted_data,
passphrase_identity,
passphrase,
}
}
}
impl Identity for EncryptedIdentity {
fn unwrap_file_key(&self, _stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
// 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<Result<FileKey, AgeError>> {
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<dyn Identity> = 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.

View File

@@ -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};