Revision history for Crypt-Age
0.003 2026-09-01 00:11:25Z
- Crypt::Age::Header->parse and ->parse_from_fh now reject a header that
carries no recipient stanza at all -- a version line followed directly by
the "--- <mac>" footer. The age header grammar is "header = v1-line
1*stanza end", one or more, so such a header is structurally invalid, but
it used to parse: it returned a header object with an empty stanza list,
advanced the caller's offset past it, and its MAC even verified, being a
well-formed MAC over a header the grammar forbids. The file then failed a
call later in unwrap_file_key with "No matching identity found", which
names the caller's keys as the cause of a file that is addressed to
nobody at all. It is now refused at parse time with "age header must
carry at least one recipient stanza: the file key is wrapped once per
stanza, so a header with none can never be unwrapped by anyone, decrypt a
file encrypted to at least one recipient", and nothing about the caller's
arguments is quoted. decrypt, decrypt_file and decrypt_filehandle see the
new message in place of "No matching identity found" for such a file;
every other file reports exactly what it reported before, including a
truncated header, which keeps "Invalid age file, no valid header MAC
line" because its cause is that the input ran out, not that the grammar
was broken. This closes the read side of the clause the previous entry
closed for the write side, and it is an interop difference as well as a
tidiness one: measured on such a file, rage 0.12.1 rejects it at parse
time as "Unknown age format" while age 1.2.1 parses it and reports "no
identity matched any of the recipients". This implementation was the most
permissive of the three and now refuses where rage does. No wire bytes
move: no valid age file has ever had a stanza-less header, and none of
the 143 upstream test vectors carries one. The POD of parse and
parse_from_fh states the requirement and quotes the message.
- The four streaming wrappers now document the same failure surface. Each of
encrypt_file, encrypt_filehandle, decrypt_file and decrypt_filehandle
reaches the same guards, but their "Dies if" clauses had drifted to four
different lengths: encrypt_file carried no enumeration at all, only "Dies
on error (file not found, permission denied, etc)"; decrypt_file and
decrypt_filehandle never named the identities ArrayRef shape and emptiness
requirements, though encrypt_filehandle had named the recipients
counterpart all along; and decrypt_file did not name the
missing-argument guard its own croaks make just as real. All four now
enumerate what they die on and point at encrypt or decrypt for the two
quoted messages rather than carrying a third and fourth copy of them,
which is what kept them out of step in the first place. Every clause was
checked against the live croak before being written. No behaviour changes;
no guard was added, removed or moved.
- Crypt::Age::Header->create now refuses an empty recipients ArrayRef
instead of returning a header with no stanzas. Passing [] satisfied the
shape check, wrapped the file key for nobody and produced a header of a
version line and a MAC over it. The age header grammar is "header =
v1-line 1*stanza end" -- one or more stanzas -- so that was not a valid
header, and the file it started could never be decrypted by anyone, the
caller included: the file key is generated per file and kept nowhere
else, so it went with the header. Nothing said so at the time. Measured
on a file built from such a header, rage 0.12.1 refuses it as "Unknown
age format" and age 1.2.1 parses it and reports "no identity matched any
of the recipients" -- both correct, both arriving after the plaintext is
the only copy and then gone. It now croaks "recipients must not be empty:
this method wraps the file key once per entry, so a header with no
stanzas can never be unwrapped, pass at least one recipient", after the
shape check and before the file key is touched. Only a caller using
Header->create directly is affected; encrypt, encrypt_file and
encrypt_filehandle have always rejected an empty list one layer up.
Those two guards in Crypt::Age move onto the same message form in this
release, so the empty list reads one way whichever layer catches it:
"at least one recipient required" becomes the message above with "this
method encrypts to every entry, so with none the result can never be
decrypted" as its reason, and "at least one identity required" becomes
"identities must not be empty: this method decrypts with whichever entry
matches, so with none there is nothing that could match, pass at least
one identity". Callers matching on either old text see the new one; both
checks accept and reject exactly what they did before, and both still
fire before any file key is generated or any header is parsed, so an
empty list still costs nothing but the exception. The identities side
closes no defect of its own -- Header->unwrap_file_key already died on an
empty list, with "No matching identity found", and still does. The POD of
create, encrypt, decrypt and encrypt_filehandle states the requirement
and quotes the messages.
- Crypt::Age::Keys->decode_secret_key and ->decode_public_key no longer
quote the human-readable part they decoded when it is not the expected
one. Both croaked "Invalid secret key HRP: expected 'age-secret-key-',
got '...'" and its public-key equivalent, where the value after "got" is
whatever bech32_decode returned as the HRP -- everything before the last
"1" of the string the caller passed, so a prefix of the caller's own
material, written into an exception raised inside this module and from
there into any log that catches it. They now report "Invalid secret key
HRP: expected the literal age-secret-key- prefix, pass an age identity
rather than a recipient or some other Bech32 string" and "Invalid public
key HRP: expected the literal age prefix, pass an age recipient rather
than an identity or some other Bech32 string": the expected HRP is a
constant of the format and is named, the one that arrived is not. The
reach is narrow and is worth stating plainly rather than dressing up.
Getting to either croak takes a Bech32 string whose checksum verifies
over the wrong HRP. Measured on real keys, every mistyping dies earlier
and quotes nothing -- a truncation as "Invalid bech32: no separator",
"Invalid bech32: empty data" or "Invalid bech32 checksum" depending on
where it cuts, trailing junk as "Invalid bech32 checksum" -- and the
everyday way to arrive, passing a public key where an identity belongs or
the reverse, puts only the other type's constant prefix in that position,
which is not key material. What made it a disclosure is a string
constructed with the opening characters of a real key as its HRP, which
is a deliberate input and not an accident. Callers matching on the old
"expected '...', got '...'" wording of either message see the new one
instead; both methods accept and reject exactly the same strings as
before. The POD of both states the new message and this reach.
- Crypt::Age's own recipients and identities checks now report the same
message shape as their counterparts in Crypt::Age::Header, so one caller
mistake reads one way whichever layer catches it. "recipients must be an
array ref" becomes "recipients must be an ArrayRef: this method encrypts
to every entry, pass [$recipient] rather than $recipient", and "identities
must be an array ref" becomes "identities must be an ArrayRef: this method
decrypts with whichever entry matches, pass [$identity] rather than
$identity". These are the messages callers actually get: encrypt,
encrypt_file, encrypt_filehandle, decrypt, decrypt_file and
decrypt_filehandle all check the shape before handing the list to
Header->create or ->unwrap_file_key, so Header's wording is unreachable
through the public API, and a caller who hit one form and searched the
documentation for the other found nothing. The clause after the colon
states what this layer does with the list rather than what Header does
with it, and as before nothing about the argument is interpolated -- a
bare string in the identities parameter is a secret key. Only the text
moves: both checks accept and reject exactly what they accepted and
rejected before. The separate emptiness checks beside them move in the
same release, in the entry below. The POD of encrypt and decrypt now
quotes both messages.
- Crypt::Age::Header->unwrap_file_key and ->create now require their list
argument to really be an ArrayRef, and croak "identities must be an
ArrayRef: this method tries each entry in turn, pass [$identity] rather
than $identity" and "recipients must be an ArrayRef: this method wraps the
file key once per entry, pass [$recipient] rather than $recipient" for
every other shape. Neither checked before, so a caller passing a single
identity or recipient bare -- one identity does not look like a list --
reached a raw dereference. On unwrap_file_key that was a disclosure of
secret key material, not merely a bad message: perl's "Can't use string
(\"...\") as an ARRAY ref while \"strict refs\" in use" quotes the first 32
characters of the offending string, so an identity was written into an
exception raised inside this module, on its way into any log, bug report
or terminal scrollback that caught it, where the caller can no longer
redact it. A recipient is a public key, so create was not a leak, but it
is the same defect one call away and the swap of recipient and identity
puts a secret key in that parameter too. undef and other refs came back as
"Can't use an undefined value as an ARRAY reference" or "Not an ARRAY
reference", both blaming a line in Header.pm for a mistake made one frame
up. The new messages quote no part of the argument. The string, file and
filehandle API was never affected -- Crypt::Age's own recipients and
identities checks already stand in front of both calls -- so this reaches
only callers using Crypt::Age::Header directly. The POD of both methods
states the requirement.
- Crypt::Age::Header->parse now requires the scalar behind its first
argument to be defined, and croaks "data must refer to a defined scalar:
this method reads the age file out of it, assign the bytes before passing
\$data". A ScalarRef to an undefined scalar satisfies the two shape checks
above -- it really is a ScalarRef -- and used to raise ten "Use of
uninitialized value" warnings from inside Header.pm, from the byte-string
scan, from seven reads of an in-memory handle opened on nothing, and from
the version line readline returned as undef, before croaking "Invalid age
version". All ten carried Header.pm line numbers for a mistake made one
frame up and none of them told the caller anything actionable. Callers
matching on the old "Invalid age version" message for this case see the
new one instead; that is the point, since a ref to nothing is a mistake in
the call, not a statement about data that arrived.
- Crypt::Age::Header->parse_from_fh no longer warns twice out of Header.pm
when the handle is already at end of input. readline returns undef there,
and chomp and string eq on it warned before the correct croak. An absent
version line is now carried into the same check as an empty one, so the
message is unchanged. This is what an empty ciphertext reaches through
Crypt::Age::decrypt, and what an empty $data reaches through parse, so
both are warning-free now as well; only the warnings are gone, the errors
are the same.
- Crypt::Age::Header->parse now requires its second argument to really be a
ScalarRef as well, and croaks "offset must be a ScalarRef: this method
writes the new offset back through it, pass \$offset rather than $offset"
for every other shape. Nothing checked it before, so a plain string, undef
or another kind of ref reached a raw dereference and came back as perl's
own "Can't use string (\"...\") as a SCALAR ref while \"strict refs\" in
use", "Can't use an undefined value as a SCALAR reference" or "Not a
SCALAR reference" -- the first of those quoting the caller's own string
into the message, and all three blaming a line inside Header.pm for a
mistake made one frame up. The message quotes no part of the argument,
which for a caller who passed the two arguments the other way round is the
ciphertext. Both argument shapes are now checked before anything looks at
the data, so a call malformed in its second argument is never reported
first as a complaint about the first one's contents. The POD of parse now
states the requirement.
- Crypt::Age::Header->parse now requires its first argument to really be a
ScalarRef and croaks "data must be a ScalarRef: this method opens it, and a
plain string is a filename, pass \$data rather than $data" for every other
shape. The argument used to go straight to open, where a plain string is a
FILENAME: a caller who wrote parse($data, \$offset) without the backslash
opened and parsed the file that string named -- returning a header built
from that file's bytes and advancing the caller's offset -- instead of
getting a type error, and where the string named nothing readable the
misuse was reported as "Invalid age input: cannot read". undef no longer
warns "Use of uninitialized value" out of Header.pm about a mistake made
one frame up, and another kind of ref no longer arrives as "cannot read"
either, which named neither cause nor fix. The message quotes no part of
the argument, which on this path is either ciphertext or a filesystem path.
The POD of parse now states the requirement.
- Crypt::Age::Header->parse now rejects a ScalarRef whose scalar holds a code
point above 0xFF with a message naming the cause and the fix --
"data must be a byte string: it holds a code point above 0xFF, read it with
:raw rather than decoding it" -- instead of "Invalid age input: cannot
read", which named neither, and perl's "code points over 0xFF" warning no
longer fires. Unlike the same check in encrypt, decrypt, encrypt_payload
and decrypt_payload, this one only reads the caller's scalar and never
downgrades it, since it is not a copy; note that on the success path perl's
own in-memory open still downgrades it in place, as it always did. The POD
of parse now states the byte-string requirement.
- Crypt::Age::Primitives::encrypt_payload and decrypt_payload now reject a
character string the way encrypt and decrypt already do, with a message
naming the cause and the fix -- "plaintext must be a byte string: it holds a
code point above 0xFF, encode it before passing it in", and the ciphertext
counterpart advising :raw rather than decoding -- instead of passing perl's
EINVAL through from the in-memory open as "Cannot open input string: Invalid
argument". Perl's own "code points over 0xFF" warning no longer fires
either, because the check runs before the open. The POD of both methods now
states the byte-string requirement.
- encrypt and decrypt now reject a character string with a message that names
the cause and the fix, instead of passing perl's EINVAL through from the
in-memory open: "plaintext must be a byte string: it holds a code point
above 0xFF, encode it before passing it in", and the ciphertext counterpart
advising :raw rather than encoding, since wide ciphertext means binary that
was decoded by mistake. Perl's own "code points over 0xFF" warning no longer
fires either, because the check runs before the open. The POD now states the
byte-string requirement for all six methods -- including its honest limit: a
character string whose code points all fit in a byte is indistinguishable
from bytes and is encrypted as Latin-1.
- The LIMITATIONS section now documents both suffixes of the recipient
rejection, ", got undef" and ", got an AGE-SECRET-KEY-1 identity", instead of
only the base message.
- Crypt::Age::Keys::bech32_decode no longer echoes the character it rejected.
It now reports "Invalid bech32 character at offset N", a 0-based offset into
the string that was passed in, so substr($encoded, $N, 1) lands on the
character without quoting it. No byte of a real key was ever at risk --
every character of an encoded age key is inside the Bech32 charset, so none
can reach that branch -- but the method is public and takes any string, so a
passphrase handed to it by mistake had one of its bytes put in an exception.
Callers matching on the old message text must update.
- encrypt and decrypt now croak instead of die when an in-memory open fails,
so the error reports the caller's position rather than Crypt/Age.pm. This is
reachable: perl refuses to open a string holding code points above 0xFF,
which is what a caller gets for passing decoded characters where the API
wants bytes.
- An undef entry in the recipients array no longer emits two "Use of
uninitialized value" warnings ahead of the error explaining it; it is
rejected before any string operation touches it and reported as ", got
undef" in the same message shape as the identity hint. An undef entry in
identities is skipped without warning, which is what already happened to
any identity that does not match -- no outcome changes there, only the noise.
- SECURITY: two error paths no longer echo what the caller passed in. Header
parsing died with "Invalid age version: $version_line", where $version_line
is the first line of the supplied ciphertext -- and since the read is
newline-delimited, an input without a newline put its entire content in the
exception. Hand decrypt a plaintext by mistake and the whole thing lands in
whatever logs that exception. It now names only the version line it expected.
Likewise the recipient dispatch died with "Unsupported recipient format:
$recipient": swap the recipients and identities arguments -- both are just
strings -- and the full secret key was echoed. It now reports the 0-based
index of the offending entry, plus a hint when the string looks like an
AGE-SECRET-KEY-1 identity. Both messages are now built from literals and an
integer; no byte of the input reaches them. Note that age 1.2.1 and rage
0.12.1 both echo the key here -- for a CLI it was already in argv, whereas a
library exception ends up in the caller's log, so this deviation is deliberate.
- Crypt::Age::Header::create now accepts an all-uppercase AGE1... recipient.
The dispatch tested /^age1/ case-sensitively while its identity counterpart
used /i and decode_public_key accepted either case, so a key this
distribution itself called valid was refused by encrypt. BIP-173 makes the
all-uppercase form the same encoding of the same string and rage 0.12.1
accepts it. A mixed-case recipient still dies -- now in bech32_decode's
mixed-case guard rather than the prefix test. Verified with the CSPRNG
pinned: both spellings of one key produce byte-identical ciphertext, and the
recipient string never reaches the file, so nothing about the format changes.
- The documented example keys are now an actual keypair. README.md and both
the SYNOPSIS and KEY FORMAT sections of Crypt::Age presented a public and a
secret key together, but the public key did not belong to the secret key --
copying both out of the documentation produced "No matching identity found".
- Test coverage for error paths that had none: both HRP mismatches in
Crypt::Age::Keys, the recipient rejection, and the version-line rejection.
The two leak fixes above are pinned by searching the message for a fragment
of the secret rather than by matching its text, so a future edit that puts a
different part of the input back in still fails the test.
- t/04-interop.t now runs its whole block once per CLI it finds instead of
resolving `age || rage` and exercising only the first. On a machine with
both installed it reports 120 assertions, 60 tagged [age] and 60 tagged
[rage], and names each binary and version up front; with one it reports 60,
and with neither it still skips. Verified against age 1.2.1 and rage 0.12.1.
A comment describing the 0-byte-plaintext case had the two implementations
backwards -- age writes an empty -o file, rage writes none -- and is
corrected; the code already handled both.
- The documented secret-key example was not a valid key. Both README.md and
the KEY FORMAT section of Crypt::Age carried a bech32 string whose checksum
does not verify, so anyone pasting it got "Invalid bech32 checksum" rather
than a working identity.
- Crypt::Age now documents what it does not implement -- scrypt (passphrase),
SSH and the post-quantum/tagged recipient types, and ASCII armor -- and
which error each produces, instead of leaving "compatible with age" to be
read as covering the whole format.
- The streaming decrypt methods now document that a failure does not undo what
was already written: decrypt_file and decrypt_filehandle release each chunk
as it authenticates, so plaintext is already in the caller's output when
they die on a truncated or corrupt payload. Each released chunk is
individually authentic; the message is not. Callers making a security
decision were given no way to know this from the POD before.
- Several POD claims that were false against the code are corrected: the
SYNOPSIS of Crypt::Age::Primitives called derive_payload_key without the
nonce it requires and died; encrypt_payload_fh claimed to return the
concatenated ciphertext when it returns nothing and writes to the handle;
the ephemeral_public attribute claimed to be set on every stanza when the
parse path leaves it undef; and the filehandle methods claimed to die on
"file not found", which they cannot, since they open no files.
- paranoid_read, bech32_encode and bech32_decode are documented.
- Crypt::Age::Keys::bech32_decode now rejects a string that mixes upper- and
lowercase, dying with "Invalid bech32: mixed case" before it even looks for
the separator. BIP-173 requires this of decoders and c2sp.org/age repeats it
for the key encoding; the checksum is computed over the lowercase form
regardless, so a mixed-case key previously verified and decoded. age 1.2.1
and rage 0.12.1 both reject such a string, so this closes a gap rather than
tightening past them. All-uppercase and all-lowercase forms both still
decode, and to the same bytes.
- Crypt::Age::Stanza::to_bytes_for_mac and encode_body_base64 are removed.
Neither was called anywhere in the distribution, and both were traps for a
future caller: to_bytes_for_mac's name asserts a role in the header MAC that
it has in neither direction -- Header::create re-serializes through
to_string, Header::parse_from_fh MACs the literal bytes it read -- while
encode_body_base64 returned the body unwrapped, which only coincides with
the wire format because an X25519 body encodes to 43 characters and so never
reaches to_string's 64-column wrap. The free function encode_base64_no_padding,
which both delegated to and which to_string uses, is unchanged.
0.002 2026-08-19 22:50:05Z
- The distribution now carries the upstream age test kit: 143 vectors from
C2SP/CCTV, run by t/07-testkit.t. 68 of them exercise this implementation
and pass; the remaining 75 are skipped with a stated reason, since they
test ASCII armor and the scrypt and post-quantum recipient types, none of
which are implemented. Unlike t/04-interop.t this needs no age binary on
PATH, so a machine without one is no longer a machine without a
compatibility proof.
- STREAM payload decryption no longer decides a chunk's finality from
end-of-file. A chunk is final because it authenticates under the
final-flag nonce, and any data after the final chunk now invalidates the
payload. Files that previously decrypted as if they had ended cleanly --
a spurious empty final chunk, trailing garbage, a second final chunk --
are rejected, and a full-length chunk written with the non-final nonce
releases its plaintext before the error instead of nothing at all.
- Age headers whose stanza arguments contain anything outside printable
ASCII (0x21-0x7e) are now rejected, as the format's ABNF requires. This
applies to every stanza line regardless of recipient type -- a stray byte
invalidates the whole header rather than merely making that one stanza
ignorable. The error no longer quotes the offending line back.
- cpanfile now pins CryptX to 0.067, the release that introduced
Crypt::PK::X25519. It previously required CryptX with no minimum at all,
so an older one satisfied the declared prerequisites and then failed at
runtime.
- A malformed X25519 stanza is now rejected while the header is parsed, as
the format requires: one that does not carry exactly one argument after the
type, whose argument is not the canonical unpadded base64 encoding of a
32-byte value, or whose body is not exactly 32 bytes. These are header
failures and die from Crypt::Age::Header->parse. Previously an extra
argument was silently ignored, and a wrong-length body came back as "No
matching identity found". Stanzas of other recipient types are unaffected
and are still ignored rather than rejected.
- Stanza serialization now emits the empty final line the format requires
when a stanza body's base64 encoding is an exact multiple of 64
characters. Files this distribution writes are byte-identical to before,
since an X25519 body never reaches that boundary.
- Unpadded base64 in headers is now decoded strictly: padding characters,
characters outside the base64 alphabet, impossible lengths and
non-canonical encodings are rejected rather than repaired, as the format
requires. Malformed age files that previously decoded now die.
- Crypt::Age::Header->verify_mac now compares the header MAC with
Crypt::Misc::slow_eq instead of a plain string eq, so a wrong MAC is no
longer rejected at the first differing byte. It returns 1 or 0 instead of
1 or the empty string; a MAC of the wrong length, or none at all, is still
false, no longer warns, and is never fatal.
- X25519 key exchange now aborts when the shared secret is all zero, as
c2sp.org/age requires. This is the low-order point check: it affects
decryption of a file carrying a low-order ephemeral share and encryption
to a low-order recipient key, both of which would otherwise proceed with a
wrapping key an attacker can predict.
- Header MAC is now verified against the literal header bytes read from the
file instead of a re-serialization of the parsed stanzas. Files whose
stanza formatting differs from ours but is valid per the spec now verify
correctly; output is unchanged, the write path still serializes.
- Crypt::Age::Header->parse no longer warns "Use of uninitialized value in
seek" when the offset scalar it is handed is undef; it is treated as 0
again, as before the filehandle rewrite.
- Removed the unfinished Crypt::Age::Stanza::Scrypt, which was never usable
(it failed to compile) and had no public API. scrypt/passphrase recipients
remain unimplemented.
- Fixed the method names in the encrypt_filehandle/decrypt_filehandle POD
synopses, which showed encrypt_file/decrypt_file.
0.001 2026-01-11 03:51:40Z
- Initial release
- X25519 recipient support
- Compatible with age/rage CLI tools
- Fixed payload key derivation to use nonce as HKDF salt per age spec
- Comprehensive POD documentation for all modules