Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1fff552
age: Add a test comparing sync and async armor writing
str4d Dec 7, 2025
f6e7d9a
age: armor: Fix AsyncWrite chucked encoding
ifdario Feb 20, 2025
8cb8338
age: Test that encrypted identity decryption fails if we can't get a …
str4d Dec 7, 2025
e6bdbe6
Return a key decryption failure error when the user provides no password
ldesgoui Jan 26, 2025
dfa5dca
age: Test that the empty plugin name is rejected
str4d Dec 7, 2025
ceb6faa
age: Reject empty plugin name
str4d Dec 7, 2025
1465e60
age 0.11.2
str4d Dec 7, 2025
1ff02de
age: Return error instead of panicking on empty passphrase
str4d Apr 21, 2026
582247d
age: Fix panic in debug mode on truncated ciphertext
str4d Apr 21, 2026
4e6ce24
age: Document security implication of `scrypt::Identity::set_max_work…
str4d Apr 21, 2026
fc4164f
age: Fix panics on weird error formats in plugin responses
str4d Apr 21, 2026
cd2e00d
age: Replace file-key panics with errors in `plugin::IdentityPluginV1`
str4d Apr 21, 2026
85c747c
age: Zeroize intermediate buffer when parsing `x25519::Identity`
str4d Apr 21, 2026
6fe47a2
age: Add some zeroization to `IdentityFile`
str4d Apr 22, 2026
8e5145f
age: Limit recipient and identity files to 16 MiB
str4d Apr 22, 2026
e3cf83d
age: Limit SSH keys to 16 kiB
str4d Apr 22, 2026
e97d2d7
CI: Switch to Go 1.24 for interop tests
str4d Jan 2, 2026
f456910
Reformat audit files to match `cargo-vet 0.10.2` format
str4d Apr 22, 2026
aa5b1d6
cargo vet prune
str4d Apr 22, 2026
681face
age 0.11.3
str4d Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/interop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ jobs:
-H 'Authorization: token ${{ secrets.AGE_STATUS_ACCESS_TOKEN }}' \
--data '{"state": "pending", "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", "description": "In progress", "context": "Interoperability tests / Build age"}'

- name: Set up Go 1.19
- name: Set up Go 1.24
uses: actions/setup-go@v5
with:
go-version: 1.19
go-version: 1.24
id: go

- name: Use specified FiloSottile/age commit
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions age/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ to 1.0.0 are beta releases.

## [Unreleased]

## [0.11.3] - 2026-04-22
### Changed
- Recipient and identity files (parsed via `age::IdentityFile` or
`age::cli_common::{read_recipients, read_identities}`) is now limited to at
most 16 MiB, matching the Go implementation.
- `age::cli_common::read_identities` now limits SSH keys to at most 16 kiB,
matching the Go implementation.

### Fixed
- `age::plugin`:
- `{RecipientPluginV1, IdentityPluginV1}` no longer panic when a plugin sends
an unusually-formatted error in phase 2.
- `IdentityPluginV1` no longer panics when a plugin violates the specification
and returns a file key for a file index that was not provided, or sends more
than one file key per file index.
- `age::ssh::EncryptedKey::decrypt` now returns an error instead of panicking
when given an empty passphrase.
- `age::stream::StreamReader` no longer panics in debug mode when seeking on a
ciphertext truncated to just after the nonce (i.e. with zero chunk data).

## [0.11.2] - 2025-12-07
### Fixed
- `age::armor::ArmoredWriter::poll_write` no longer panics when writing more
than 6144 bytes.
- `age::encrypted::Identity` no longer causes a panic when being decrypted if
the `age::Callbacks::request_passphrase` impl returns `None`.
- `age::plugin::{Identity, RecipientPluginV1, IdentityPluginV1}` now correctly
reject the empty plugin name (like `age::plugin::Recipient` already was).

## [0.6.1, 0.7.2, 0.8.2, 0.9.3, 0.10.1, 0.11.1] - 2024-11-18
### Security
- Fixed a security vulnerability that could allow an attacker to execute an
Expand Down
2 changes: 1 addition & 1 deletion age/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "age"
description = "[BETA] A simple, secure, and modern encryption library."
version = "0.11.1"
version = "0.11.3"
authors.workspace = true
repository.workspace = true
readme = "README.md"
Expand Down
13 changes: 12 additions & 1 deletion age/src/cli_common/identities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ use crate::{identity::IdentityFile, Identity};
#[cfg(feature = "armor")]
use crate::{armor::ArmoredReader, cli_common::file_io::InputReader};

#[cfg(feature = "ssh")]
use crate::util::LimitedReader;

#[cfg(feature = "ssh")]
const SSH_IDENTITY_SIZE_LIMIT: usize = 1 << 14; // 16 KiB

/// Reads identities from the provided files.
///
/// `filenames` may contain at most one entry of `"-"`, which will be interpreted as
/// reading from standard input. An error will be returned if `stdin_guard` is guarding an
/// existing usage of standard input.
///
/// Each file in `filenames` may be at most 16 MiB. SSH keys are limited to 16 kiB.
pub fn read_identities(
filenames: Vec<String>,
max_work_factor: Option<u8>,
Expand Down Expand Up @@ -123,7 +131,10 @@ pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(

// Try parsing as a single multi-line SSH identity.
#[cfg(feature = "ssh")]
match crate::ssh::Identity::from_buffer(&mut reader, Some(filename.clone())) {
match crate::ssh::Identity::from_buffer(
LimitedReader::new(&mut reader, SSH_IDENTITY_SIZE_LIMIT),
Some(filename.clone()),
) {
Ok(crate::ssh::Identity::Unsupported(k)) => {
return Err(ReadError::UnsupportedKey(filename, k).into())
}
Expand Down
7 changes: 6 additions & 1 deletion age/src/cli_common/recipients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::io::{self, BufReader};
use super::StdinGuard;
use super::{identities::parse_identity_files, ReadError};
use crate::identity::RecipientsAccumulator;
use crate::util::LimitedReader;
use crate::{x25519, Recipient};

#[cfg(feature = "plugin")]
Expand All @@ -17,6 +18,8 @@ use crate::ssh;
#[cfg(any(feature = "armor", feature = "plugin"))]
use crate::EncryptError;

const RECIPIENT_FILE_SIZE_LIMIT: usize = 1 << 24; // 16 MiB

/// Handles error mapping for the given SSH recipient parser.
///
/// Returns `Ok(None)` if the parser finds a parseable value that should be ignored. This
Expand Down Expand Up @@ -126,6 +129,8 @@ fn read_recipients_list<R: io::BufRead>(
/// `recipients_file_strings` and `identity_strings` may collectively contain at most one
/// entry of `"-"`, which will be interpreted as reading from standard input. An error
/// will be returned if `stdin_guard` is guarding an existing usage of standard input.
///
/// Each file in `recipients_file_strings` and `identity_strings` may be at most 16 MiB.
pub fn read_recipients(
recipient_strings: Vec<String>,
recipients_file_strings: Vec<String>,
Expand All @@ -146,7 +151,7 @@ pub fn read_recipients(
}
_ => e,
})?;
let buf = BufReader::new(f);
let buf = LimitedReader::new(BufReader::new(f), RECIPIENT_FILE_SIZE_LIMIT);
read_recipients_list(&arg, buf, &mut recipients)?;
}

Expand Down
44 changes: 33 additions & 11 deletions age/src/encrypted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ impl<R: io::Read, C: Callbacks> IdentityState<R, C> {
filename = filename.unwrap_or_default()
)) {
Some(passphrase) => passphrase,
None => todo!(),
None => Err(DecryptError::KeyDecryptionFailed)?,
};

let mut identity = scrypt::Identity::new(passphrase);
Expand Down Expand Up @@ -216,10 +216,10 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=
const TEST_RECIPIENT: &str = "age1ysxuaeqlk7xd8uqsh8lsnfwt9jzzjlqf49ruhpjrrj5yatlcuf7qke4pqe";

#[derive(Clone)]
struct MockCallbacks(Arc<Mutex<Option<&'static str>>>);
struct MockCallbacks(Arc<Mutex<Option<Option<&'static str>>>>);

impl MockCallbacks {
fn new(passphrase: &'static str) -> Self {
fn new(passphrase: Option<&'static str>) -> Self {
MockCallbacks(Arc::new(Mutex::new(Some(passphrase))))
}
}
Expand All @@ -239,9 +239,13 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=

/// This intentionally panics if called twice.
fn request_passphrase(&self, _: &str) -> Option<SecretString> {
Some(SecretString::from(
self.0.lock().unwrap().take().unwrap().to_owned(),
))
self.0
.lock()
.unwrap()
.take()
.expect("passphrase is only input once")
.to_owned()
.map(SecretString::from)
}
}

Expand All @@ -258,10 +262,28 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=
// Unwrapping with the wrong passphrase fails.
{
let buf = ArmoredReader::new(TEST_ENCRYPTED_IDENTITY.as_bytes());
let identity =
Identity::from_buffer(buf, None, MockCallbacks::new("wrong passphrase"), None)
.unwrap()
.unwrap();
let identity = Identity::from_buffer(
buf,
None,
MockCallbacks::new(Some("wrong passphrase")),
None,
)
.unwrap()
.unwrap();

if let Err(e) = identity.unwrap_stanzas(&wrapped).unwrap() {
assert!(matches!(e, DecryptError::KeyDecryptionFailed));
} else {
panic!("Should have failed");
}
}

// Unwrapping fails if we cannot obtain a passphrase.
{
let buf = ArmoredReader::new(TEST_ENCRYPTED_IDENTITY.as_bytes());
let identity = Identity::from_buffer(buf, None, MockCallbacks::new(None), None)
.unwrap()
.unwrap();

if let Err(e) = identity.unwrap_stanzas(&wrapped).unwrap() {
assert!(matches!(e, DecryptError::KeyDecryptionFailed));
Expand All @@ -274,7 +296,7 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=
let identity = Identity::from_buffer(
buf,
None,
MockCallbacks::new(TEST_ENCRYPTED_IDENTITY_PASSPHRASE),
MockCallbacks::new(Some(TEST_ENCRYPTED_IDENTITY_PASSPHRASE)),
None,
)
.unwrap()
Expand Down
11 changes: 9 additions & 2 deletions age/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ use crate::{wfl, wlnfl};
#[cfg(feature = "plugin")]
use age_core::format::Stanza;

#[cfg(feature = "plugin")]
use crate::plugin::CMD_ERROR;

/// Errors returned when converting an identity file to a recipients file.
#[derive(Debug)]
pub enum IdentityFileConvertError {
Expand Down Expand Up @@ -110,8 +113,12 @@ pub enum PluginError {
#[cfg(feature = "plugin")]
impl From<Stanza> for PluginError {
fn from(mut s: Stanza) -> Self {
assert!(s.tag == "error");
let kind = s.args.remove(0);
assert_eq!(s.tag, CMD_ERROR);
let kind = if s.args.is_empty() {
"unknown".into()
} else {
s.args.remove(0)
};
PluginError::Other {
kind,
metadata: s.args,
Expand Down
25 changes: 21 additions & 4 deletions age/src/identity.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
use std::fs::File;
use std::io;
use std::{
fs::File,
io::{self, BufRead},
};

use crate::{x25519, Callbacks, DecryptError, EncryptError, IdentityFileConvertError, NoCallbacks};
use zeroize::Zeroize;

use crate::{
util::LimitedReader, x25519, Callbacks, DecryptError, EncryptError, IdentityFileConvertError,
NoCallbacks,
};

#[cfg(feature = "cli-common")]
use crate::cli_common::file_io::InputReader;

#[cfg(feature = "plugin")]
use crate::plugin;

const IDENTITY_SIZE_LIMIT: usize = 1 << 24; // 16 MiB

/// The supported kinds of identities within an [`IdentityFile`].
#[derive(Clone)]
enum IdentityFileEntry {
Expand Down Expand Up @@ -41,6 +50,8 @@ impl IdentityFileEntry {
}

/// A list of identities that has been parsed from some input file.
///
/// The maximum supported file size is 16 MiB.
pub struct IdentityFile<C: Callbacks> {
filename: Option<String>,
identities: Vec<IdentityFileEntry>,
Expand Down Expand Up @@ -70,8 +81,10 @@ impl IdentityFile<NoCallbacks> {
fn parse_identities<R: io::BufRead>(filename: Option<String>, data: R) -> io::Result<Self> {
let mut identities = vec![];

let data = LimitedReader::new(data, IDENTITY_SIZE_LIMIT);

for (line_number, line) in data.lines().enumerate() {
let line = line?;
let mut line = line?;
if line.is_empty() || line.starts_with('#') {
continue;
}
Expand All @@ -96,6 +109,8 @@ impl IdentityFile<NoCallbacks> {
#[cfg(not(feature = "plugin"))]
let _: () = identity;
} else {
line.zeroize();

// Return a line number in place of the line, so we don't leak the file
// contents in error messages.
return Err(io::Error::new(
Expand All @@ -114,6 +129,8 @@ impl IdentityFile<NoCallbacks> {
},
));
}

line.zeroize();
}

Ok(IdentityFile {
Expand Down
Loading
Loading