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

@@ -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.