age-core: Add plaintext size argument to aead_decrypt

This implements the same mitigation as FiloSottile/age for the multi-key
attack. The age crate was already checking these lengths for built-in
recipient types; this change extends the mitigation to other crates that
reuse the age primitives, such as age plugins.
This commit is contained in:
Jack Grigg 2020-12-26 01:16:41 +00:00
parent 95fe900549
commit d0d55872a7
6 changed files with 48 additions and 22 deletions

View file

@ -7,6 +7,9 @@ and this project adheres to Rust's notion of
to 1.0.0 are beta releases.
## [Unreleased]
### Added
- `age_core::format::FILE_KEY_BYTES` constant.
### Changed
- The stanza prefix `-> ` and trailing newline are now formal parts of the age
stanza; `age_core::format::write::age_stanza` now includes them in its output,
@ -17,6 +20,14 @@ to 1.0.0 are beta releases.
API `age_core::format::read::legacy_age_stanza` accepts either kind of stanza
body encoding (the legacy minimal encoding, and the new explicit encoding).
### Security
- `age_core::primitives::aead_decrypt` now takes a `size` argument, checked
against the plaintext length. This is to mitigate multi-key attacks, where a
ciphertext can be crafted that decrypts successfully under multiple keys.
Short ciphertexts can only target two keys, which has limited impact. See
[this commit message](https://github.com/FiloSottile/age/commit/2194f6962c8bb3bca8a55f313d5b9302596b593b)
for more details.
## [0.5.0] - 2020-11-22
### Added
- Several structs used when implementing the `age::Identity` and

View file

@ -7,17 +7,20 @@ use secrecy::{ExposeSecret, Secret};
/// The prefix identifying an age stanza.
const STANZA_TAG: &str = "-> ";
/// A file key for encrypting or decrypting an age file.
pub struct FileKey(Secret<[u8; 16]>);
/// The length of an age file key.
pub const FILE_KEY_BYTES: usize = 16;
impl From<[u8; 16]> for FileKey {
fn from(file_key: [u8; 16]) -> Self {
/// A file key for encrypting or decrypting an age file.
pub struct FileKey(Secret<[u8; FILE_KEY_BYTES]>);
impl From<[u8; FILE_KEY_BYTES]> for FileKey {
fn from(file_key: [u8; FILE_KEY_BYTES]) -> Self {
FileKey(Secret::new(file_key))
}
}
impl ExposeSecret<[u8; 16]> for FileKey {
fn expose_secret(&self) -> &[u8; 16] {
impl ExposeSecret<[u8; FILE_KEY_BYTES]> for FileKey {
fn expose_secret(&self) -> &[u8; FILE_KEY_BYTES] {
self.0.expose_secret()
}
}

View file

@ -1,13 +1,13 @@
//! Primitive cryptographic operations used across various `age` components.
use chacha20poly1305::{
aead::{self, Aead, NewAead},
aead::{self, generic_array::typenum::Unsigned, Aead, NewAead},
ChaChaPoly1305,
};
use hkdf::Hkdf;
use sha2::Sha256;
/// `encrypt[key](plaintext)`
/// `encrypt[key](plaintext)` - encrypts a message with a one-time key.
///
/// ChaCha20-Poly1305 from [RFC 7539] with a zero nonce.
///
@ -18,12 +18,24 @@ pub fn aead_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
.expect("we won't overflow the ChaCha20 block counter")
}
/// `decrypt[key](ciphertext)`
/// `decrypt[key](ciphertext)` - decrypts a message of an expected fixed size.
///
/// ChaCha20-Poly1305 from [RFC 7539] with a zero nonce.
///
/// The message size is limited to mitigate multi-key attacks, where a ciphertext can be
/// crafted that decrypts successfully under multiple keys. Short ciphertexts can only
/// target two keys, which has limited impact.
///
/// [RFC 7539]: https://tools.ietf.org/html/rfc7539
pub fn aead_decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>, aead::Error> {
pub fn aead_decrypt(
key: &[u8; 32],
size: usize,
ciphertext: &[u8],
) -> Result<Vec<u8>, aead::Error> {
if ciphertext.len() != size + <ChaChaPoly1305<c2_chacha::Ietf> as Aead>::TagSize::to_usize() {
return Err(aead::Error);
}
let c = ChaChaPoly1305::<c2_chacha::Ietf>::new(key.into());
c.decrypt(&[0; 12].into(), ciphertext)
}
@ -50,7 +62,7 @@ mod tests {
let key = [14; 32];
let plaintext = b"12345678";
let encrypted = aead_encrypt(&key, plaintext);
let decrypted = aead_decrypt(&key, &encrypted).unwrap();
let decrypted = aead_decrypt(&key, plaintext.len(), &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
}

View file

@ -1,5 +1,5 @@
use age_core::{
format::{FileKey, Stanza},
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, aead_encrypt},
};
use rand::{rngs::OsRng, RngCore};
@ -19,7 +19,7 @@ const SCRYPT_SALT_LABEL: &[u8] = b"age-encryption.org/v1/scrypt";
const ONE_SECOND: Duration = Duration::from_secs(1);
const SALT_LEN: usize = 16;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
/// Pick an scrypt work factor that will take around 1 second on this device.
///
@ -130,10 +130,10 @@ impl<'a> crate::Identity for Identity<'a> {
};
Some(
aead_decrypt(&enc_key, &stanza.body)
aead_decrypt(&enc_key, FILE_KEY_BYTES, &stanza.body)
.map(|mut pt| {
// It's ours!
let file_key: [u8; 16] = pt[..].try_into().unwrap();
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
file_key.into()
})

View file

@ -1,5 +1,5 @@
use age_core::{
format::{FileKey, Stanza},
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, hkdf},
};
use i18n_embed_fl::fl;
@ -109,11 +109,11 @@ impl UnencryptedKey {
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
Some(
aead_decrypt(&enc_key, &stanza.body)
aead_decrypt(&enc_key, FILE_KEY_BYTES, &stanza.body)
.map_err(DecryptError::from)
.map(|mut pt| {
// It's ours!
let file_key: [u8; 16] = pt[..].try_into().unwrap();
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
file_key.into()
}),

View file

@ -1,7 +1,7 @@
//! The "x25519" recipient type, native to age.
use age_core::{
format::{FileKey, Stanza},
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, aead_encrypt, hkdf},
};
use bech32::ToBase32;
@ -26,7 +26,7 @@ pub(super) const X25519_RECIPIENT_TAG: &str = "X25519";
const X25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/X25519";
pub(super) const EPK_LEN_BYTES: usize = 32;
pub(super) const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
pub(super) const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16;
/// A secret key for decrypting an age file.
#[derive(Clone)]
@ -106,11 +106,11 @@ impl crate::Identity for Identity {
let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, shared_secret.as_bytes());
aead_decrypt(&enc_key, &encrypted_file_key)
aead_decrypt(&enc_key, FILE_KEY_BYTES, &encrypted_file_key)
.ok()
.map(|mut pt| {
// It's ours!
let file_key: [u8; 16] = pt[..].try_into().unwrap();
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
Ok(file_key.into())
})