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:
164
crates/notsecrets/src/decrypt.rs
Normal file
164
crates/notsecrets/src/decrypt.rs
Normal 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,36 +3,53 @@ use crate::identities::{FileKey, Identity, Stanza};
|
|||||||
|
|
||||||
/// An identity whose key material is stored in an age-encrypted file.
|
/// 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
|
/// The outer file is decrypted on each call to `unwrap_file_key` using a
|
||||||
/// `passphrase_identity`. The decrypted content must be an `AGE-SECRET-KEY-1...`
|
/// scrypt passphrase identity. The decrypted content must be an
|
||||||
/// bech32 string, which is parsed as an `X25519Identity`.
|
/// `AGE-SECRET-KEY-1...` bech32 string, which is parsed as an `X25519Identity`.
|
||||||
pub struct EncryptedIdentity {
|
pub struct EncryptedIdentity {
|
||||||
/// Raw bytes of the age-encrypted identity file.
|
/// Raw bytes of the age-encrypted identity file.
|
||||||
encrypted_data: Vec<u8>,
|
encrypted_data: Vec<u8>,
|
||||||
/// Identity used to decrypt the outer age file (typically `ScryptIdentity`).
|
/// Passphrase used to decrypt the outer age file via `ScryptIdentity`.
|
||||||
passphrase_identity: Box<dyn Identity>,
|
passphrase: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EncryptedIdentity {
|
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 {
|
Self {
|
||||||
encrypted_data,
|
encrypted_data,
|
||||||
passphrase_identity,
|
passphrase,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Identity for EncryptedIdentity {
|
impl Identity for EncryptedIdentity {
|
||||||
fn unwrap_file_key(&self, _stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
||||||
// Full implementation in Task 9 after Decryptor is available.
|
let decryptor = crate::Decryptor::with_identities(vec![Box::new(
|
||||||
// The integration test below is marked #[ignore] until then.
|
crate::identities::scrypt::ScryptIdentity::new(self.passphrase.clone()),
|
||||||
let _ = &self.encrypted_data;
|
)]);
|
||||||
let _ = self.passphrase_identity.as_ref();
|
let plaintext = match decryptor.decrypt(&self.encrypted_data) {
|
||||||
Some(Err(AgeError::CryptoError(
|
Ok(p) => p,
|
||||||
"EncryptedIdentity requires Decryptor — implemented in Task 9".to_string(),
|
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.
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod decrypt;
|
||||||
pub mod encrypt;
|
pub mod encrypt;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
@@ -8,6 +9,7 @@ pub mod _legacy;
|
|||||||
|
|
||||||
pub use error::AgeError;
|
pub use error::AgeError;
|
||||||
pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity};
|
pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity, ScryptIdentity, SshEd25519Identity, SshRsaIdentity, EncryptedIdentity};
|
||||||
|
pub use decrypt::Decryptor;
|
||||||
pub use encrypt::Encryptor;
|
pub use encrypt::Encryptor;
|
||||||
pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient};
|
pub use recipients::{Recipient, X25519Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient};
|
||||||
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
||||||
|
|||||||
Reference in New Issue
Block a user