diff --git a/age/CHANGELOG.md b/age/CHANGELOG.md index 39c0d4b..3ddde8c 100644 --- a/age/CHANGELOG.md +++ b/age/CHANGELOG.md @@ -10,8 +10,20 @@ to 1.0.0 are beta releases. ## [Unreleased] ### Added +- `age::Decryptor::{decrypt, decrypt_async, is_scrypt}` +- `age::scrypt`, providing recipient and identity types for passphrase-based + encryption. - Partial French translation! +### Changed +- `age::Decryptor` is now an opaque struct instead of an enum with `Recipients` + and `Passphrase` variants. + +### Removed +- `age::decryptor::PassphraseDecryptor` (use `age::Decryptor` with + `age::scrypt::Identity` instead). +- `age::decryptor::RecipientsDecryptor` (use `age::Decryptor` instead). + ## [0.10.0] - 2024-02-04 ### Added - Russian translation! diff --git a/age/benches/throughput.rs b/age/benches/throughput.rs index 7a3c8e5..a259218 100644 --- a/age/benches/throughput.rs +++ b/age/benches/throughput.rs @@ -70,10 +70,7 @@ fn bench(c: &mut Criterion_) { output.finish().unwrap(); b.iter(|| { - let decryptor = match Decryptor::new_buffered(&ct_buf[..]).unwrap() { - Decryptor::Recipients(decryptor) => decryptor, - _ => panic!(), - }; + let decryptor = Decryptor::new_buffered(&ct_buf[..]).unwrap(); let mut input = decryptor .decrypt(iter::once(&identity as &dyn age::Identity)) .unwrap(); diff --git a/age/i18n/en-US/age.ftl b/age/i18n/en-US/age.ftl index 4f6af14..830ed4a 100644 --- a/age/i18n/en-US/age.ftl +++ b/age/i18n/en-US/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa @@ -57,6 +59,8 @@ err-header-mac-invalid = Header MAC is invalid err-key-decryption = Failed to decrypt an encrypted key +err-mixed-recipient-passphrase = {-scrypt-recipient} can't be used with other recipients. + err-no-matching-keys = No matching keys found err-unknown-format = Unknown {-age} format. diff --git a/age/i18n/es-AR/age.ftl b/age/i18n/es-AR/age.ftl index 657760a..7e39bd6 100644 --- a/age/i18n/es-AR/age.ftl +++ b/age/i18n/es-AR/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/i18n/fr/age.ftl b/age/i18n/fr/age.ftl index bcaf072..0c3a9ea 100644 --- a/age/i18n/fr/age.ftl +++ b/age/i18n/fr/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/i18n/it/age.ftl b/age/i18n/it/age.ftl index 64a1ff2..1f65f7a 100644 --- a/age/i18n/it/age.ftl +++ b/age/i18n/it/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/i18n/ru/age.ftl b/age/i18n/ru/age.ftl index 410a67a..d8e7f25 100644 --- a/age/i18n/ru/age.ftl +++ b/age/i18n/ru/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/i18n/zh-CN/age.ftl b/age/i18n/zh-CN/age.ftl index f38cf1d..5767ee5 100644 --- a/age/i18n/zh-CN/age.ftl +++ b/age/i18n/zh-CN/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/i18n/zh-TW/age.ftl b/age/i18n/zh-TW/age.ftl index 871ba93..8180861 100644 --- a/age/i18n/zh-TW/age.ftl +++ b/age/i18n/zh-TW/age.ftl @@ -13,6 +13,8 @@ -age = age -rage = rage +-scrypt-recipient = scrypt::Recipient + -openssh = OpenSSH -ssh-keygen = ssh-keygen -ssh-rsa = ssh-rsa diff --git a/age/src/encrypted.rs b/age/src/encrypted.rs index ec405b0..c573723 100644 --- a/age/src/encrypted.rs +++ b/age/src/encrypted.rs @@ -3,14 +3,13 @@ use std::{cell::Cell, io}; use crate::{ - decryptor::PassphraseDecryptor, fl, Callbacks, DecryptError, Decryptor, EncryptError, - IdentityFile, IdentityFileEntry, + fl, scrypt, Callbacks, DecryptError, Decryptor, EncryptError, IdentityFile, IdentityFileEntry, }; /// The state of the encrypted age identity. enum IdentityState { Encrypted { - decryptor: PassphraseDecryptor, + decryptor: Decryptor, max_work_factor: Option, }, Decrypted(Vec), @@ -51,8 +50,13 @@ impl IdentityState { None => todo!(), }; + let mut identity = scrypt::Identity::new(passphrase); + if let Some(max_work_factor) = max_work_factor { + identity.set_max_work_factor(max_work_factor); + } + decryptor - .decrypt(&passphrase, max_work_factor) + .decrypt(Some(&identity as _).into_iter()) .map_err(|e| { if matches!(e, DecryptError::DecryptionFailed) { DecryptError::KeyDecryptionFailed @@ -92,17 +96,15 @@ impl Identity { callbacks: C, max_work_factor: Option, ) -> Result, DecryptError> { - match Decryptor::new(data)? { - Decryptor::Recipients(_) => Ok(None), - Decryptor::Passphrase(decryptor) => Ok(Some(Identity { - state: Cell::new(IdentityState::Encrypted { - decryptor, - max_work_factor, - }), - filename, - callbacks, - })), - } + let decryptor = Decryptor::new(data)?; + Ok(decryptor.is_scrypt().then_some(Identity { + state: Cell::new(IdentityState::Encrypted { + decryptor, + max_work_factor, + }), + filename, + callbacks, + })) } /// Returns the recipients contained within this encrypted identity. diff --git a/age/src/error.rs b/age/src/error.rs index 0416998..4a8ce96 100644 --- a/age/src/error.rs +++ b/age/src/error.rs @@ -110,6 +110,10 @@ pub enum EncryptError { /// The plugin's binary name. binary_name: String, }, + /// [`scrypt::Recipient`] was mixed with other recipient types. + /// + /// [`scrypt::Recipient`]: crate::scrypt::Recipient + MixedRecipientAndPassphrase, /// Errors from a plugin. #[cfg(feature = "plugin")] #[cfg_attr(docsrs, doc(cfg(feature = "plugin")))] @@ -131,6 +135,7 @@ impl Clone for EncryptError { Self::MissingPlugin { binary_name } => Self::MissingPlugin { binary_name: binary_name.clone(), }, + Self::MixedRecipientAndPassphrase => Self::MixedRecipientAndPassphrase, #[cfg(feature = "plugin")] Self::Plugin(e) => Self::Plugin(e.clone()), } @@ -147,6 +152,9 @@ impl fmt::Display for EncryptError { wlnfl!(f, "err-missing-plugin", plugin_name = binary_name.as_str())?; wfl!(f, "rec-missing-plugin") } + EncryptError::MixedRecipientAndPassphrase => { + wfl!(f, "err-mixed-recipient-passphrase") + } #[cfg(feature = "plugin")] EncryptError::Plugin(errors) => match &errors[..] { [] => unreachable!(), @@ -168,7 +176,6 @@ impl std::error::Error for EncryptError { match self { EncryptError::EncryptedIdentities(inner) => Some(inner), EncryptError::Io(inner) => Some(inner), - #[cfg(feature = "plugin")] _ => None, } } diff --git a/age/src/format.rs b/age/src/format.rs index 43064f3..23d86ac 100644 --- a/age/src/format.rs +++ b/age/src/format.rs @@ -1,11 +1,13 @@ //! The age file format. -use age_core::format::Stanza; use std::io::{self, BufRead, Read, Write}; +use age_core::format::{grease_the_joint, Stanza}; + use crate::{ error::DecryptError, primitives::{HmacKey, HmacWriter}, + scrypt, EncryptError, }; #[cfg(feature = "async")] @@ -32,13 +34,22 @@ pub(crate) struct HeaderV1 { } impl HeaderV1 { - pub(crate) fn new(recipients: Vec, mac_key: HmacKey) -> Self { + pub(crate) fn new(recipients: Vec, mac_key: HmacKey) -> Result { let mut header = HeaderV1 { recipients, mac: [0; 32], encoded_bytes: None, }; + if header.no_scrypt() { + // Keep the joint well oiled! + header.recipients.push(grease_the_joint()); + } + + if !header.is_valid() { + return Err(EncryptError::MixedRecipientAndPassphrase); + } + let mut mac = HmacWriter::new(mac_key); cookie_factory::gen(write::header_v1_minus_mac(&header), &mut mac) .expect("can serialize Header into HmacWriter"); @@ -46,7 +57,7 @@ impl HeaderV1 { .mac .copy_from_slice(mac.finalize().into_bytes().as_slice()); - header + Ok(header) } pub(crate) fn verify_mac(&self, mac_key: HmacKey) -> Result<(), hmac::digest::MacError> { @@ -61,6 +72,33 @@ impl HeaderV1 { } mac.verify(&self.mac) } + + fn any_scrypt(&self) -> bool { + self.recipients + .iter() + .any(|r| r.tag == scrypt::SCRYPT_RECIPIENT_TAG) + } + + /// Checks whether the header contains a single recipient of type `scrypt`. + /// + /// This can be used along with [`Self::no_scrypt`] to enforce the structural + /// requirements on the v1 header. + pub(crate) fn valid_scrypt(&self) -> bool { + self.any_scrypt() && self.recipients.len() == 1 + } + + /// Checks whether the header contains no `scrypt` recipients. + /// + /// This can be used along with [`Self::valid_scrypt`] to enforce the structural + /// requirements on the v1 header. + pub(crate) fn no_scrypt(&self) -> bool { + !self.any_scrypt() + } + + /// Enforces structural requirements on the v1 header. + pub(crate) fn is_valid(&self) -> bool { + self.valid_scrypt() || self.no_scrypt() + } } impl Header { diff --git a/age/src/lib.rs b/age/src/lib.rs index fd9d410..03d602e 100644 --- a/age/src/lib.rs +++ b/age/src/lib.rs @@ -8,8 +8,10 @@ //! There are several ways to use these: //! - For most cases (including programmatic usage), use [`Encryptor::with_recipients`] //! with [`x25519::Recipient`], and [`Decryptor`] with [`x25519::Identity`]. -//! - APIs are available for passphrase-based encryption and decryption. These should -//! only be used with passphrases that were provided by (or generated for) a human. +//! - For passphrase-based encryption and decryption, use [`scrypt::Recipient`] and +//! [`scrypt::Identity`], or the helper method [`Encryptor::with_user_passphrase`]. +//! These should only be used with passphrases that were provided by (or generated for) +//! a human. //! - For compatibility with existing SSH keys, enable the `ssh` feature flag, and use //! [`ssh::Recipient`] and [`ssh::Identity`]. //! @@ -56,10 +58,7 @@ //! // ... and decrypt the obtained ciphertext to the plaintext again. //! # fn decrypt(key: age::x25519::Identity, encrypted: Vec) -> Result, age::DecryptError> { //! let decrypted = { -//! let decryptor = match age::Decryptor::new(&encrypted[..])? { -//! age::Decryptor::Recipients(d) => d, -//! _ => unreachable!(), -//! }; +//! let decryptor = age::Decryptor::new(&encrypted[..])?; //! //! let mut decrypted = vec![]; //! let mut reader = decryptor.decrypt(iter::once(&key as &dyn age::Identity))?; @@ -86,15 +85,16 @@ //! ``` //! use age::secrecy::Secret; //! use std::io::{Read, Write}; +//! use std::iter; //! //! # fn run_main() -> Result<(), ()> { //! let plaintext = b"Hello world!"; -//! let passphrase = "this is not a good passphrase"; +//! let passphrase = Secret::new("this is not a good passphrase".to_owned()); //! //! // Encrypt the plaintext to a ciphertext using the passphrase... -//! # fn encrypt(passphrase: &str, plaintext: &[u8]) -> Result, age::EncryptError> { +//! # fn encrypt(passphrase: Secret, plaintext: &[u8]) -> Result, age::EncryptError> { //! let encrypted = { -//! let encryptor = age::Encryptor::with_user_passphrase(Secret::new(passphrase.to_owned())); +//! let encryptor = age::Encryptor::with_user_passphrase(passphrase.clone()); //! //! let mut encrypted = vec![]; //! let mut writer = encryptor.wrap_output(&mut encrypted)?; @@ -107,15 +107,12 @@ //! # } //! //! // ... and decrypt the ciphertext to the plaintext again using the same passphrase. -//! # fn decrypt(passphrase: &str, encrypted: Vec) -> Result, age::DecryptError> { +//! # fn decrypt(passphrase: Secret, encrypted: Vec) -> Result, age::DecryptError> { //! let decrypted = { -//! let decryptor = match age::Decryptor::new(&encrypted[..])? { -//! age::Decryptor::Passphrase(d) => d, -//! _ => unreachable!(), -//! }; +//! let decryptor = age::Decryptor::new(&encrypted[..])?; //! //! let mut decrypted = vec![]; -//! let mut reader = decryptor.decrypt(&Secret::new(passphrase.to_owned()), None)?; +//! let mut reader = decryptor.decrypt(iter::once(&age::scrypt::Identity::new(passphrase) as _))?; //! reader.read_to_end(&mut decrypted); //! //! decrypted @@ -123,7 +120,7 @@ //! # Ok(decrypted) //! # } //! # let decrypted = decrypt( -//! # passphrase, +//! # passphrase.clone(), //! # encrypt(passphrase, &plaintext[..]).map_err(|_| ())? //! # ).map_err(|_| ())?; //! @@ -153,7 +150,7 @@ mod util; pub use error::{DecryptError, EncryptError}; pub use identity::{IdentityFile, IdentityFileEntry}; pub use primitives::stream; -pub use protocol::{decryptor, Decryptor, Encryptor}; +pub use protocol::{Decryptor, Encryptor}; #[cfg(feature = "armor")] pub use primitives::armor; @@ -170,7 +167,7 @@ pub use i18n::localizer; // pub mod encrypted; -mod scrypt; +pub mod scrypt; pub mod x25519; #[cfg(feature = "plugin")] @@ -193,7 +190,7 @@ pub trait Identity { /// /// This method is part of the `Identity` trait to expose age's [one joint] for /// external implementations. You should not need to call this directly; instead, pass - /// identities to [`RecipientsDecryptor::decrypt`]. + /// identities to [`Decryptor::decrypt`]. /// /// Returns: /// - `Some(Ok(file_key))` on success. @@ -201,7 +198,6 @@ pub trait Identity { /// - `None` if the recipient stanza does not match this key. /// /// [one joint]: https://www.imperialviolet.org/2016/05/16/agility.html - /// [`RecipientsDecryptor::decrypt`]: protocol::decryptor::RecipientsDecryptor::decrypt fn unwrap_stanza(&self, stanza: &Stanza) -> Option>; /// Attempts to unwrap any of the given stanzas, which are assumed to come from the @@ -209,7 +205,7 @@ pub trait Identity { /// /// This method is part of the `Identity` trait to expose age's [one joint] for /// external implementations. You should not need to call this directly; instead, pass - /// identities to [`RecipientsDecryptor::decrypt`]. + /// identities to [`Decryptor::decrypt`]. /// /// Returns: /// - `Some(Ok(file_key))` on success. @@ -217,7 +213,6 @@ pub trait Identity { /// - `None` if none of the recipient stanzas match this identity. /// /// [one joint]: https://www.imperialviolet.org/2016/05/16/agility.html - /// [`RecipientsDecryptor::decrypt`]: protocol::decryptor::RecipientsDecryptor::decrypt fn unwrap_stanzas(&self, stanzas: &[Stanza]) -> Option> { stanzas.iter().find_map(|stanza| self.unwrap_stanza(stanza)) } diff --git a/age/src/primitives/armor.rs b/age/src/primitives/armor.rs index ee7dead..85033ca 100644 --- a/age/src/primitives/armor.rs +++ b/age/src/primitives/armor.rs @@ -321,12 +321,7 @@ enum ArmorIs { /// # } /// # fn decrypt(identity: age::x25519::Identity, encrypted: Vec) -> Result, age::DecryptError> { /// # let decrypted = { -/// # let decryptor = match age::Decryptor::new( -/// # age::armor::ArmoredReader::new(&encrypted[..]) -/// # )? { -/// # age::Decryptor::Recipients(d) => d, -/// # _ => unreachable!(), -/// # }; +/// # let decryptor = age::Decryptor::new(age::armor::ArmoredReader::new(&encrypted[..]))?; /// # let mut decrypted = vec![]; /// # let mut reader = decryptor.decrypt(iter::once(&identity as &dyn age::Identity))?; /// # reader.read_to_end(&mut decrypted); @@ -693,12 +688,7 @@ enum StartPos { /// /// # fn decrypt(identity: age::x25519::Identity, encrypted: Vec) -> Result, age::DecryptError> { /// let decrypted = { -/// let decryptor = match age::Decryptor::new( -/// age::armor::ArmoredReader::new(&encrypted[..]) -/// )? { -/// age::Decryptor::Recipients(d) => d, -/// _ => unreachable!(), -/// }; +/// let decryptor = age::Decryptor::new(age::armor::ArmoredReader::new(&encrypted[..]))?; /// /// let mut decrypted = vec![]; /// let mut reader = decryptor.decrypt(iter::once(&identity as &dyn age::Identity))?; diff --git a/age/src/protocol.rs b/age/src/protocol.rs index fe0ff46..98b90e7 100644 --- a/age/src/protocol.rs +++ b/age/src/protocol.rs @@ -1,6 +1,6 @@ //! Encryption and decryption routines for age. -use age_core::{format::grease_the_joint, secrecy::SecretString}; +use age_core::secrecy::SecretString; use rand::{rngs::OsRng, RngCore}; use std::io::{self, BufRead, Read, Write}; @@ -8,15 +8,13 @@ use crate::{ error::{DecryptError, EncryptError}, format::{Header, HeaderV1}, keys::{mac_key, new_file_key, v1_payload_key}, - primitives::stream::{PayloadKey, Stream, StreamWriter}, - scrypt, Recipient, + primitives::stream::{PayloadKey, Stream, StreamReader, StreamWriter}, + scrypt, Identity, Recipient, }; #[cfg(feature = "async")] use futures::io::{AsyncBufRead, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -pub mod decryptor; - pub(crate) struct Nonce([u8; 16]); impl AsRef<[u8]> for Nonce { @@ -47,16 +45,10 @@ impl Nonce { } } -/// Handles the various types of age encryption. -enum EncryptorType { - /// Encryption to a list of recipients identified by keys. - Keys(Vec>), - /// Encryption to a passphrase. - Passphrase(SecretString), -} - /// Encryptor for creating an age file. -pub struct Encryptor(EncryptorType); +pub struct Encryptor { + recipients: Vec>, +} impl Encryptor { /// Constructs an `Encryptor` that will create an age file encrypted to a list of @@ -64,7 +56,7 @@ impl Encryptor { /// /// Returns `None` if no recipients were provided. pub fn with_recipients(recipients: Vec>) -> Option { - (!recipients.is_empty()).then_some(Encryptor(EncryptorType::Keys(recipients))) + (!recipients.is_empty()).then_some(Encryptor { recipients }) } /// Returns an `Encryptor` that will create an age file encrypted with a passphrase. @@ -76,29 +68,24 @@ impl Encryptor { /// /// [`x25519::Identity`]: crate::x25519::Identity pub fn with_user_passphrase(passphrase: SecretString) -> Self { - Encryptor(EncryptorType::Passphrase(passphrase)) + Encryptor { + recipients: vec![Box::new(scrypt::Recipient::new(passphrase))], + } } /// Creates the header for this age file. fn prepare_header(self) -> Result<(Header, Nonce, PayloadKey), EncryptError> { let file_key = new_file_key(); - let recipients = match self.0 { - EncryptorType::Keys(recipients) => { - let mut stanzas = Vec::with_capacity(recipients.len() + 1); - for recipient in recipients { - stanzas.append(&mut recipient.wrap_file_key(&file_key)?); - } - // Keep the joint well oiled! - stanzas.push(grease_the_joint()); - stanzas - } - EncryptorType::Passphrase(passphrase) => { - scrypt::Recipient { passphrase }.wrap_file_key(&file_key)? + let recipients = { + let mut stanzas = Vec::with_capacity(self.recipients.len() + 1); + for recipient in self.recipients { + stanzas.append(&mut recipient.wrap_file_key(&file_key)?); } + stanzas }; - let header = HeaderV1::new(recipients, mac_key(&file_key)); + let header = HeaderV1::new(recipients, mac_key(&file_key))?; let nonce = Nonce::random(); let payload_key = v1_payload_key(&file_key, &header, &nonce).expect("MAC is correct"); @@ -140,41 +127,49 @@ impl Encryptor { } /// Decryptor for an age file. -pub enum Decryptor { - /// Decryption with a list of identities. - Recipients(decryptor::RecipientsDecryptor), - /// Decryption with a passphrase. - Passphrase(decryptor::PassphraseDecryptor), -} - -impl From> for Decryptor { - fn from(decryptor: decryptor::RecipientsDecryptor) -> Self { - Decryptor::Recipients(decryptor) - } -} - -impl From> for Decryptor { - fn from(decryptor: decryptor::PassphraseDecryptor) -> Self { - Decryptor::Passphrase(decryptor) - } +pub struct Decryptor { + /// The age file. + input: R, + /// The age file's header. + header: Header, + /// The age file's AEAD nonce + nonce: Nonce, } impl Decryptor { fn from_v1_header(input: R, header: HeaderV1, nonce: Nonce) -> Result { // Enforce structural requirements on the v1 header. - let any_scrypt = header - .recipients - .iter() - .any(|r| r.tag == scrypt::SCRYPT_RECIPIENT_TAG); - - if any_scrypt && header.recipients.len() == 1 { - Ok(decryptor::PassphraseDecryptor::new(input, Header::V1(header), nonce).into()) - } else if !any_scrypt { - Ok(decryptor::RecipientsDecryptor::new(input, Header::V1(header), nonce).into()) + if header.is_valid() { + Ok(Self { + input, + header: Header::V1(header), + nonce, + }) } else { Err(DecryptError::InvalidHeader) } } + + /// Returns `true` if the age file is encrypted to a passphrase. + pub fn is_scrypt(&self) -> bool { + match &self.header { + Header::V1(header) => header.valid_scrypt(), + Header::Unknown(_) => false, + } + } + + fn obtain_payload_key<'a>( + &self, + mut identities: impl Iterator, + ) -> Result { + match &self.header { + Header::V1(header) => identities + .find_map(|key| key.unwrap_stanzas(&header.recipients)) + .unwrap_or(Err(DecryptError::NoMatchingKeys)) + .and_then(|file_key| v1_payload_key(&file_key, header, &self.nonce)), + Header::Unknown(_) => unreachable!(), + } + } } impl Decryptor { @@ -199,6 +194,17 @@ impl Decryptor { Header::Unknown(_) => Err(DecryptError::UnknownFormat), } } + + /// Attempts to decrypt the age file. + /// + /// If successful, returns a reader that will provide the plaintext. + pub fn decrypt<'a>( + self, + identities: impl Iterator, + ) -> Result, DecryptError> { + self.obtain_payload_key(identities) + .map(|payload_key| Stream::decrypt(payload_key, self.input)) + } } impl Decryptor { @@ -247,6 +253,17 @@ impl Decryptor { Header::Unknown(_) => Err(DecryptError::UnknownFormat), } } + + /// Attempts to decrypt the age file. + /// + /// If successful, returns a reader that will provide the plaintext. + pub fn decrypt_async<'a>( + self, + identities: impl Iterator, + ) -> Result, DecryptError> { + self.obtain_payload_key(identities) + .map(|payload_key| Stream::decrypt_async(payload_key, self.input)) + } } #[cfg(feature = "async")] @@ -284,7 +301,7 @@ mod tests { use super::{Decryptor, Encryptor}; use crate::{ identity::{IdentityFile, IdentityFileEntry}, - x25519, Identity, Recipient, + scrypt, x25519, Identity, Recipient, }; #[cfg(feature = "async")] @@ -311,10 +328,7 @@ mod tests { w.finish().unwrap(); } - let d = match Decryptor::new(&encrypted[..]) { - Ok(Decryptor::Recipients(d)) => d, - _ => panic!(), - }; + let d = Decryptor::new(&encrypted[..]).unwrap(); let mut r = d.decrypt(identities).unwrap(); let mut decrypted = vec![]; r.read_to_end(&mut decrypted).unwrap(); @@ -365,7 +379,7 @@ mod tests { } } - let d = match { + let d = { let f = Decryptor::new_async(&encrypted[..]); pin_mut!(f); @@ -376,9 +390,6 @@ mod tests { Poll::Pending => panic!("Unexpected Pending"), } } - } { - Decryptor::Recipients(d) => d, - _ => panic!(), }; let decrypted = { @@ -443,12 +454,12 @@ mod tests { w.finish().unwrap(); } - let d = match Decryptor::new(&encrypted[..]) { - Ok(Decryptor::Passphrase(d)) => d, - _ => panic!(), - }; + let d = Decryptor::new(&encrypted[..]).unwrap(); let mut r = d - .decrypt(&SecretString::new("passphrase".to_string()), None) + .decrypt( + Some(&scrypt::Identity::new(SecretString::new("passphrase".to_string())) as _) + .into_iter(), + ) .unwrap(); let mut decrypted = vec![]; r.read_to_end(&mut decrypted).unwrap(); diff --git a/age/src/protocol/decryptor.rs b/age/src/protocol/decryptor.rs deleted file mode 100644 index 46622b8..0000000 --- a/age/src/protocol/decryptor.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Decryptors for age. - -use age_core::{ - format::{FileKey, Stanza}, - secrecy::SecretString, -}; -use std::io::Read; - -use super::Nonce; -use crate::{ - error::DecryptError, - format::Header, - keys::v1_payload_key, - primitives::stream::{PayloadKey, Stream, StreamReader}, - scrypt, Identity, -}; - -#[cfg(feature = "async")] -use futures::io::AsyncRead; - -struct BaseDecryptor { - /// The age file. - input: R, - /// The age file's header. - header: Header, - /// The age file's AEAD nonce - nonce: Nonce, -} - -impl BaseDecryptor { - fn obtain_payload_key(&self, mut filter: F) -> Result - where - F: FnMut(&[Stanza]) -> Option>, - { - match &self.header { - Header::V1(header) => filter(&header.recipients) - .unwrap_or(Err(DecryptError::NoMatchingKeys)) - .and_then(|file_key| v1_payload_key(&file_key, header, &self.nonce)), - Header::Unknown(_) => unreachable!(), - } - } -} - -/// Decryptor for an age file encrypted to a list of recipients. -pub struct RecipientsDecryptor(BaseDecryptor); - -impl RecipientsDecryptor { - pub(super) fn new(input: R, header: Header, nonce: Nonce) -> Self { - RecipientsDecryptor(BaseDecryptor { - input, - header, - nonce, - }) - } - - fn obtain_payload_key<'a>( - &self, - mut identities: impl Iterator, - ) -> Result { - self.0 - .obtain_payload_key(|r| identities.find_map(|key| key.unwrap_stanzas(r))) - } -} - -impl RecipientsDecryptor { - /// Attempts to decrypt the age file. - /// - /// If successful, returns a reader that will provide the plaintext. - pub fn decrypt<'a>( - self, - identities: impl Iterator, - ) -> Result, DecryptError> { - self.obtain_payload_key(identities) - .map(|payload_key| Stream::decrypt(payload_key, self.0.input)) - } -} - -#[cfg(feature = "async")] -#[cfg_attr(docsrs, doc(cfg(feature = "async")))] -impl RecipientsDecryptor { - /// Attempts to decrypt the age file. - /// - /// If successful, returns a reader that will provide the plaintext. - pub fn decrypt_async<'a>( - self, - identities: impl Iterator, - ) -> Result, DecryptError> { - self.obtain_payload_key(identities) - .map(|payload_key| Stream::decrypt_async(payload_key, self.0.input)) - } -} - -/// Decryptor for an age file encrypted with a passphrase. -pub struct PassphraseDecryptor(BaseDecryptor); - -impl PassphraseDecryptor { - pub(super) fn new(input: R, header: Header, nonce: Nonce) -> Self { - PassphraseDecryptor(BaseDecryptor { - input, - header, - nonce, - }) - } - - fn obtain_payload_key( - &self, - passphrase: &SecretString, - max_work_factor: Option, - ) -> Result { - let identity = scrypt::Identity { - passphrase, - max_work_factor, - }; - - self.0.obtain_payload_key(|r| identity.unwrap_stanzas(r)) - } -} - -impl PassphraseDecryptor { - /// Attempts to decrypt the age file. - /// - /// `max_work_factor` is the maximum accepted work factor. If `None`, the default - /// maximum is adjusted to around 16 seconds of work. - /// - /// If successful, returns a reader that will provide the plaintext. - pub fn decrypt( - self, - passphrase: &SecretString, - max_work_factor: Option, - ) -> Result, DecryptError> { - self.obtain_payload_key(passphrase, max_work_factor) - .map(|payload_key| Stream::decrypt(payload_key, self.0.input)) - } -} - -#[cfg(feature = "async")] -#[cfg_attr(docsrs, doc(cfg(feature = "async")))] -impl PassphraseDecryptor { - /// Attempts to decrypt the age file. - /// - /// `max_work_factor` is the maximum accepted work factor. If `None`, the default - /// maximum is adjusted to around 16 seconds of work. - /// - /// If successful, returns a reader that will provide the plaintext. - pub fn decrypt_async( - self, - passphrase: &SecretString, - max_work_factor: Option, - ) -> Result, DecryptError> { - self.obtain_payload_key(passphrase, max_work_factor) - .map(|payload_key| Stream::decrypt_async(payload_key, self.0.input)) - } -} diff --git a/age/src/scrypt.rs b/age/src/scrypt.rs index 6f254f3..b70f04c 100644 --- a/age/src/scrypt.rs +++ b/age/src/scrypt.rs @@ -1,3 +1,5 @@ +//! The "scrypt" passphrase-based recipient type, native to age. + use age_core::{ format::{FileKey, Stanza, FILE_KEY_BYTES}, primitives::{aead_decrypt, aead_encrypt}, @@ -83,8 +85,25 @@ fn target_scrypt_work_factor() -> u8 { }) } -pub(crate) struct Recipient { - pub(crate) passphrase: SecretString, +/// A passphrase-based recipient. Anyone with the passphrase can decrypt the file. +/// +/// If an `scrypt::Recipient` is used, it must be the only recipient for the file: it +/// can't be mixed with other recipient types and can't be used multiple times for the +/// same file. +/// +/// This API should only be used with a passphrase that was provided by (or generated +/// for) a human. For programmatic use cases, instead generate an [`x25519::Identity`]. +/// +/// [`x25519::Identity`]: crate::x25519::Identity +pub struct Recipient { + passphrase: SecretString, +} + +impl Recipient { + /// Constructs a new `Recipient` with the given passphrase. + pub fn new(passphrase: SecretString) -> Self { + Self { passphrase } + } } impl crate::Recipient for Recipient { @@ -112,12 +131,46 @@ impl crate::Recipient for Recipient { } } -pub(crate) struct Identity<'a> { - pub(crate) passphrase: &'a SecretString, - pub(crate) max_work_factor: Option, +/// A passphrase-based identity. Anyone with the passphrase can decrypt the file. +/// +/// The identity caps the amount of work that the [`Decryptor`] might have to do to +/// process received files. A fairly high default is used (targeting roughly 16 seconds of +/// work per stanza on the current machine), which might not be suitable for systems +/// processing untrusted files. +/// +/// [`Decryptor`]: crate::Decryptor +pub struct Identity { + passphrase: SecretString, + target_work_factor: u8, + max_work_factor: u8, } -impl<'a> crate::Identity for Identity<'a> { +impl Identity { + /// Constructs a new `Identity` with the given passphrase. + pub fn new(passphrase: SecretString) -> Self { + let target_work_factor = target_scrypt_work_factor(); + + // Place bounds on the work factor we will accept (roughly 16 seconds). + let max_work_factor = target_work_factor + 4; + + Self { + passphrase, + target_work_factor, + max_work_factor, + } + } + + /// Sets the maximum accepted scrypt work factor to `2^max_work_factor`. + /// + /// This method must be called before [`Self::unwrap_stanza`] to have an effect. + /// + /// [`Self::unwrap_stanza`]: crate::Identity::unwrap_stanza + pub fn set_max_work_factor(&mut self, max_work_factor: u8) { + self.max_work_factor = max_work_factor; + } +} + +impl crate::Identity for Identity { fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { if stanza.tag != SCRYPT_RECIPIENT_TAG { return None; @@ -139,12 +192,10 @@ impl<'a> crate::Identity for Identity<'a> { return Some(Err(DecryptError::InvalidHeader)); } - // Place bounds on the work factor we will accept (roughly 16 seconds). - let target = target_scrypt_work_factor(); - if log_n > self.max_work_factor.unwrap_or(target + 4) { + if log_n > self.max_work_factor { return Some(Err(DecryptError::ExcessiveWork { required: log_n, - target, + target: self.target_work_factor, })); } @@ -157,7 +208,7 @@ impl<'a> crate::Identity for Identity<'a> { Err(_) => { return Some(Err(DecryptError::ExcessiveWork { required: log_n, - target, + target: self.target_work_factor, })); } }; diff --git a/age/tests/test_vectors.rs b/age/tests/test_vectors.rs index aeecba8..765e1bb 100644 --- a/age/tests/test_vectors.rs +++ b/age/tests/test_vectors.rs @@ -1,7 +1,9 @@ -use age_core::secrecy::SecretString; use std::fs; use std::io::Read; +use age::scrypt; +use age_core::secrecy::SecretString; + #[test] #[cfg(feature = "cli-common")] fn age_test_vectors() -> Result<(), Box> { @@ -22,30 +24,29 @@ fn age_test_vectors() -> Result<(), Box> { let name = path.file_stem().unwrap().to_str().unwrap(); let expect_failure = name.starts_with("fail_"); - let res = match age::Decryptor::new(fs::File::open(&path)?)? { - age::Decryptor::Recipients(d) => { - let identities = age::cli_common::read_identities( - vec![format!( - "{}/{}_key.txt", - path.parent().unwrap().to_str().unwrap(), - name - )], - None, - &mut StdinGuard::new(false), - )?; - d.decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity)) - } - age::Decryptor::Passphrase(d) => { - let mut passphrase = String::new(); - fs::File::open(format!( - "{}/{}_password.txt", + let d = age::Decryptor::new(fs::File::open(&path)?)?; + let res = if !d.is_scrypt() { + let identities = age::cli_common::read_identities( + vec![format!( + "{}/{}_key.txt", path.parent().unwrap().to_str().unwrap(), name - ))? - .read_to_string(&mut passphrase)?; - let passphrase = SecretString::new(passphrase); - d.decrypt(&passphrase, None) - } + )], + None, + &mut StdinGuard::new(false), + )?; + d.decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity)) + } else { + let mut passphrase = String::new(); + fs::File::open(format!( + "{}/{}_password.txt", + path.parent().unwrap().to_str().unwrap(), + name + ))? + .read_to_string(&mut passphrase)?; + let passphrase = SecretString::new(passphrase); + let identity = scrypt::Identity::new(passphrase); + d.decrypt(Some(&identity as _).into_iter()) }; match (res, expect_failure) { diff --git a/age/tests/testkit.rs b/age/tests/testkit.rs index 8d8762e..d169087 100644 --- a/age/tests/testkit.rs +++ b/age/tests/testkit.rs @@ -6,6 +6,7 @@ use std::{ use age::{ armor::{ArmoredReadError, ArmoredReader}, + scrypt, secrecy::SecretString, x25519, DecryptError, Decryptor, Identity, }; @@ -131,14 +132,15 @@ fn testkit(filename: &str) { let testfile = TestFile::parse(filename); let comment = format_testkit_comment(&testfile); - match Decryptor::new(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| match d { - Decryptor::Recipients(d) => { + match Decryptor::new(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| { + if !d.is_scrypt() { let identities = get_testkit_identities(filename, &testfile); d.decrypt(identities.iter().map(|i| i as &dyn Identity)) - } - Decryptor::Passphrase(d) => { + } else { let passphrase = get_testkit_passphrase(&testfile, &comment); - d.decrypt(&passphrase, Some(16)) + let mut identity = scrypt::Identity::new(passphrase); + identity.set_max_work_factor(16); + d.decrypt(Some(&identity as _).into_iter()) } }) { Ok(mut r) => { @@ -268,18 +270,17 @@ fn testkit_buffered(filename: &str) { let testfile = TestFile::parse(filename); let comment = format_testkit_comment(&testfile); - match Decryptor::new_buffered(ArmoredReader::new(&testfile.age_file[..])).and_then( - |d| match d { - Decryptor::Recipients(d) => { - let identities = get_testkit_identities(filename, &testfile); - d.decrypt(identities.iter().map(|i| i as &dyn Identity)) - } - Decryptor::Passphrase(d) => { - let passphrase = get_testkit_passphrase(&testfile, &comment); - d.decrypt(&passphrase, Some(16)) - } - }, - ) { + match Decryptor::new_buffered(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| { + if !d.is_scrypt() { + let identities = get_testkit_identities(filename, &testfile); + d.decrypt(identities.iter().map(|i| i as &dyn Identity)) + } else { + let passphrase = get_testkit_passphrase(&testfile, &comment); + let mut identity = scrypt::Identity::new(passphrase); + identity.set_max_work_factor(16); + d.decrypt(Some(&identity as _).into_iter()) + } + }) { Ok(mut r) => { let mut payload = vec![]; let res = io::Read::read_to_end(&mut r, &mut payload); @@ -410,14 +411,15 @@ async fn testkit_async(filename: &str) { match Decryptor::new_async(ArmoredReader::from_async_reader(&testfile.age_file[..])) .await - .and_then(|d| match d { - Decryptor::Recipients(d) => { + .and_then(|d| { + if !d.is_scrypt() { let identities = get_testkit_identities(filename, &testfile); d.decrypt_async(identities.iter().map(|i| i as &dyn Identity)) - } - Decryptor::Passphrase(d) => { + } else { let passphrase = get_testkit_passphrase(&testfile, &comment); - d.decrypt_async(&passphrase, Some(16)) + let mut identity = scrypt::Identity::new(passphrase); + identity.set_max_work_factor(16); + d.decrypt_async(Some(&identity as _).into_iter()) } }) { Ok(mut r) => { @@ -550,14 +552,15 @@ async fn testkit_async_buffered(filename: &str) { match Decryptor::new_async_buffered(ArmoredReader::from_async_reader(&testfile.age_file[..])) .await - .and_then(|d| match d { - Decryptor::Recipients(d) => { + .and_then(|d| { + if !d.is_scrypt() { let identities = get_testkit_identities(filename, &testfile); d.decrypt_async(identities.iter().map(|i| i as &dyn Identity)) - } - Decryptor::Passphrase(d) => { + } else { let passphrase = get_testkit_passphrase(&testfile, &comment); - d.decrypt_async(&passphrase, Some(16)) + let mut identity = scrypt::Identity::new(passphrase); + identity.set_max_work_factor(16); + d.decrypt_async(Some(&identity as _).into_iter()) } }) { Ok(mut r) => { diff --git a/rage/src/bin/rage-mount/main.rs b/rage/src/bin/rage-mount/main.rs index 208ebea..4f58113 100644 --- a/rage/src/bin/rage-mount/main.rs +++ b/rage/src/bin/rage-mount/main.rs @@ -3,6 +3,7 @@ use age::{ armor::ArmoredReader, cli_common::{read_identities, read_secret, StdinGuard}, + scrypt, stream::StreamReader, }; use clap::{CommandFactory, Parser}; @@ -209,28 +210,33 @@ fn main() -> Result<(), Error> { let mut stdin_guard = StdinGuard::new(false); - match age::Decryptor::new_buffered(ArmoredReader::new(file))? { - age::Decryptor::Passphrase(decryptor) => { - match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) { - Ok(passphrase) => decryptor - .decrypt(&passphrase, opts.max_work_factor) + let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(file))?; + + if decryptor.is_scrypt() { + match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) { + Ok(passphrase) => { + let mut identity = scrypt::Identity::new(passphrase); + if let Some(max_work_factor) = opts.max_work_factor { + identity.set_max_work_factor(max_work_factor); + } + + decryptor + .decrypt(Some(&identity as _).into_iter()) .map_err(|e| e.into()) - .and_then(|stream| mount_stream(stream, types, mountpoint)), - Err(_) => Ok(()), + .and_then(|stream| mount_stream(stream, types, mountpoint)) } + Err(_) => Ok(()), } - age::Decryptor::Recipients(decryptor) => { - let identities = - read_identities(opts.identity, opts.max_work_factor, &mut stdin_guard)?; + } else { + let identities = read_identities(opts.identity, opts.max_work_factor, &mut stdin_guard)?; - if identities.is_empty() { - return Err(Error::MissingIdentities); - } - - decryptor - .decrypt(identities.iter().map(|i| &**i)) - .map_err(|e| e.into()) - .and_then(|stream| mount_stream(stream, types, mountpoint)) + if identities.is_empty() { + return Err(Error::MissingIdentities); } + + decryptor + .decrypt(identities.iter().map(|i| &**i)) + .map_err(|e| e.into()) + .and_then(|stream| mount_stream(stream, types, mountpoint)) } } diff --git a/rage/src/bin/rage/main.rs b/rage/src/bin/rage/main.rs index 38bbfc5..a75a397 100644 --- a/rage/src/bin/rage/main.rs +++ b/rage/src/bin/rage/main.rs @@ -6,7 +6,7 @@ use age::{ file_io, read_identities, read_or_generate_passphrase, read_recipients, read_secret, Passphrase, StdinGuard, UiCallbacks, }, - plugin, + plugin, scrypt, secrecy::ExposeSecret, Identity, }; @@ -292,55 +292,61 @@ fn decrypt(opts: AgeOptions) -> Result<(), error::DecryptError> { ], ); - match age::Decryptor::new_buffered(ArmoredReader::new(input))? { - age::Decryptor::Passphrase(decryptor) => { - if identities_were_provided { - return Err(error::DecryptError::MixedIdentityAndPassphrase); - } + let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(input))?; - // The `rpassword` crate opens `/dev/tty` directly on Unix, so we don't have - // any conflict with stdin. - #[cfg(not(unix))] - { - if !has_file_argument { - return Err(error::DecryptError::PassphraseWithoutFileArgument); + if decryptor.is_scrypt() { + if identities_were_provided { + return Err(error::DecryptError::MixedIdentityAndPassphrase); + } + + // The `rpassword` crate opens `/dev/tty` directly on Unix, so we don't have + // any conflict with stdin. + #[cfg(not(unix))] + { + if !has_file_argument { + return Err(error::DecryptError::PassphraseWithoutFileArgument); + } + } + + match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) { + Ok(passphrase) => { + let mut identity = scrypt::Identity::new(passphrase); + if let Some(max_work_factor) = opts.max_work_factor { + identity.set_max_work_factor(max_work_factor); } - } - match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) { - Ok(passphrase) => decryptor - .decrypt(&passphrase, opts.max_work_factor) + decryptor + .decrypt(Some(&identity as _).into_iter()) .map_err(|e| e.into()) - .and_then(|input| write_output(input, output)), - Err(pinentry::Error::Cancelled) => Ok(()), - Err(pinentry::Error::Timeout) => Err(error::DecryptError::PassphraseTimedOut), - Err(pinentry::Error::Encoding(e)) => { - // Pretend it is an I/O error - Err(error::DecryptError::Io(io::Error::new( - io::ErrorKind::InvalidData, - e, - ))) - } - Err(pinentry::Error::Gpg(e)) => { - // Pretend it is an I/O error - Err(error::DecryptError::Io(io::Error::new( - io::ErrorKind::Other, - format!("{}", e), - ))) - } - Err(pinentry::Error::Io(e)) => Err(error::DecryptError::Io(e)), + .and_then(|input| write_output(input, output)) } + Err(pinentry::Error::Cancelled) => Ok(()), + Err(pinentry::Error::Timeout) => Err(error::DecryptError::PassphraseTimedOut), + Err(pinentry::Error::Encoding(e)) => { + // Pretend it is an I/O error + Err(error::DecryptError::Io(io::Error::new( + io::ErrorKind::InvalidData, + e, + ))) + } + Err(pinentry::Error::Gpg(e)) => { + // Pretend it is an I/O error + Err(error::DecryptError::Io(io::Error::new( + io::ErrorKind::Other, + format!("{}", e), + ))) + } + Err(pinentry::Error::Io(e)) => Err(error::DecryptError::Io(e)), + } + } else { + if identities.is_empty() { + return Err(error::DecryptError::MissingIdentities { stdin_identity }); } - age::Decryptor::Recipients(decryptor) => { - if identities.is_empty() { - return Err(error::DecryptError::MissingIdentities { stdin_identity }); - } - decryptor - .decrypt(identities.iter().map(|i| i.as_ref() as &dyn Identity)) - .map_err(|e| e.into()) - .and_then(|input| write_output(input, output)) - } + decryptor + .decrypt(identities.iter().map(|i| i.as_ref() as &dyn Identity)) + .map_err(|e| e.into()) + .and_then(|input| write_output(input, output)) } }