NAME

Crypt::Age::Header - age file header parsing and generation

VERSION

version 0.003

SYNOPSIS

use Crypt::Age::Header;

# Create header for encryption
my $header = Crypt::Age::Header->create($file_key, \@recipient_public_keys);
my $header_text = $header->to_string;

# Parse header during decryption
my $offset = 0;
my $header = Crypt::Age::Header->parse(\$ciphertext, \$offset);

# Unwrap file key
my $file_key = $header->unwrap_file_key(\@identity_secret_keys);

DESCRIPTION

This module handles parsing and generation of age file headers.

An age file header is a text section at the beginning of an age file that contains:

  • Version line (age-encryption.org/v1)

  • One or more recipient stanzas (each wrapping the file key)

  • MAC footer (authenticates the header)

The header format is:

age-encryption.org/v1
-> X25519 <base64-ephemeral-public-key>
<base64-wrapped-file-key>
--- <base64-mac>

This is an internal module used by Crypt::Age.

stanzas

ArrayRef of Crypt::Age::Stanza objects representing recipient stanzas.

Each stanza wraps the file key for one recipient.

mac

The header MAC as raw bytes (32 bytes).

Used to authenticate the header and verify that the correct file key was unwrapped.

create

my $header = Crypt::Age::Header->create($file_key, \@recipients);

Creates a new header for encrypting to multiple recipients.

Parameters:

  • $file_key - The 16-byte file key to wrap

  • \@recipients - ArrayRef of Bech32-encoded public keys (age1...)

\@recipients must really be an ArrayRef; a single recipient still goes in a list of one. Every other shape used to reach a raw dereference and be reported by perl as "Can't use string (...) as an ARRAY ref while "strict refs" in use", "Can't use an undefined value as an ARRAY reference" or "Not an ARRAY reference", each of them blaming a line in this module for a mistake made one frame up. They are now refused, before the file key is wrapped, with "recipients must be an ArrayRef: this method wraps the file key once per entry, pass [$recipient] rather than $recipient". As elsewhere in this module the clause after the colon carries the requirement and its reason rather than a description of what arrived, since one message answers all of those shapes.

It quotes no part of the argument either. The first of perl's messages above quoted the caller's string, truncated at 32 characters; the string in this parameter is normally a public key, but the mistake that puts a bare string here is the same one that swaps recipient and identity, which is why the per-entry croak below reports that swap. See "unwrap_file_key", where the value is never public.

An empty ArrayRef is refused as well, with "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". It used to be accepted: it passed the shape check, wrapped the file key for nobody, and returned a header consisting 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 at all, and the file it started could never be decrypted by anyone, the caller included, because the file key is generated per file and kept nowhere else. rage 0.12.1 refuses such a file as "Unknown age format"; age 1.2.1 parses it and reports "no identity matched any of the recipients". Both arrive after the plaintext is unrecoverable, which is why this is refused here instead.

Returns a Crypt::Age::Header object with stanzas for each recipient and a computed MAC.

The age1 prefix is matched case-insensitively, so an all-uppercase AGE1... recipient is accepted as well -- BIP-173 defines it as the same encoding of the same key, and "decode_public_key" in Crypt::Age::Keys decodes it. A recipient mixing the two cases is not: it dies with Invalid bech32: mixed case from "bech32_decode" in Crypt::Age::Keys. Any other recipient string dies with "Unsupported recipient format at index N: expected an age1 recipient", where N is the recipient's position in \@recipients. A string that looks like an identity adds ", got an AGE-SECRET-KEY-1 identity" -- the swap of recipient and identity is the likely mistake, and both are plain strings.

An undef entry dies with that same message and ", got undef" in place of the identity hint; it is reported before any string operation touches it, so it no longer produces two "Use of uninitialized value" warnings ahead of the error that explains it.

The offending string itself is never part of the message. It may be a secret key, and the exception ends up in the caller's logs; the index locates the entry without quoting it.

The case of the recipient string does not reach the file. It is decoded to raw bytes here, and the stanza carries the ephemeral public key, not the recipient.

to_string

my $header_text = $header->to_string;

Serializes the header to text format.

Returns a string containing the version line, all stanzas, and the MAC footer, suitable for writing to the beginning of an age file.

parse_from_fh

my $header = Crypt::Age::Header->parse_from_fh($fh);

Parses an age header directly from a filehandle.

Parameters:

  • $fh - An open, readable filehandle positioned at the first byte of the header

Puts the handle into :raw mode and reads it line by line (with "\n" as the input record separator) for the duration of the call, so the caller does not need to prepare the handle's discipline beforehand. It reads the version line, every recipient stanza, and the --- MAC footer line, stopping as soon as that footer line has been consumed. On return the handle is therefore positioned at the first byte of the payload -- this is what lets "parse" call tell on it afterwards to report the new offset.

While reading, it accumulates the literal header bytes it consumed -- the version line, every stanza line exactly as read, and the --- of the footer, with no trailing space, MAC value, or newline -- and stores them on the returned object. "verify_mac" authenticates against these captured bytes, not against a re-serialization of the parsed stanzas, so a header this method accepted is exactly the header the MAC is checked against. (Header construction on the write path, "create", has no bytes to capture and re-serializes the stanzas instead.)

Returns a Crypt::Age::Header object holding the parsed stanzas, the raw MAC bytes, and the captured header bytes. It does not verify the MAC itself -- that is "verify_mac"'s job, and it only runs after a file key has been unwrapped from one of the stanzas.

Dies if:

  • the first line is not the literal age-encryption.org/v1 version line -- including when there is no first line at all, because the handle is already at end of input. That case reads as an absent version line and gets the same message, without warning about the undef that readline returned

  • a stanza body line is longer than 64 characters

  • a stanza body never reaches a line shorter than 64 characters before the handle runs out -- the required short (possibly empty) final line is missing

  • the handle runs out, or a line fails to match either a stanza start line (-> type arg1 arg2 ...) or the --- MAC footer (three dashes, a space, and a 43-character base64 MAC), before a valid MAC line has been found

  • the header is complete but carries no recipient stanza at all -- a version line followed directly by the --- MAC footer. The format's grammar is header = v1-line 1*stanza end, one or more, so such a header is structurally invalid however well-formed its MAC is

  • a stanza start line carries an argument that is empty (two spaces in a row, or a trailing space) or that contains a byte outside printable ASCII, 0x21-0x7e -- the format's argument = 1*VCHAR, where VCHAR is RFC 5234's core rule. The first argument, the stanza type, is subject to the same set: the grammar defines no separate rule for it. This check applies to every stanza line in the header regardless of type, and rejecting is deliberate -- a byte outside the set invalidates the whole header rather than merely making that one stanza ignorable

  • a stanza body, a stanza argument, or the MAC token fails the strict decoding in "decode_base64_no_padding" in Crypt::Age::Stanza -- = padding, a character outside the base64 alphabet, an impossible length, or a non-canonical encoding

  • an X25519 stanza fails the checks in "BUILD" in Crypt::Age::Stanza::X25519: other than exactly one argument after the type, an argument that does not decode to a 32-byte value, or a body that is not exactly 32 bytes

The stanza-less header is refused 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". It used to be accepted: the header parsed, and its MAC even verified, since it is a well-formed MAC over a header the grammar forbids. The file then failed 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. Implementations disagree on when such a file is rejected -- rage 0.12.1 refuses it at parse time as "Unknown age format", age 1.2.1 parses it and reports "no identity matched any of the recipients" -- and this one now refuses where rage does, at the point the grammar is violated. "create" refuses to build such a header on the write side.

A stanza of an unrecognized type is kept as a plain Crypt::Age::Stanza and is not validated beyond the structure every stanza shares -- the format requires unknown stanzas to be ignored, not rejected, since this is how recipient types are expected to be added in the future (grease). "The structure every stanza shares" does include the argument character set above: an unknown-type stanza whose arguments are all printable ASCII is ignored, one carrying a byte outside that set is a header failure, because the byte breaks the header's grammar rather than that one stanza's semantics.

This is the implementation "parse" wraps for its \$data/\$offset interface; see "parse" for that entry point.

parse

my $header = Crypt::Age::Header->parse(\$data, \$offset);

Parses an age header from encrypted data. This is a \$data/\$offset wrapper: it opens a filehandle on \$data and delegates the actual parsing to "parse_from_fh".

Parameters:

  • \$data - ScalarRef to the complete age file data

  • \$offset - ScalarRef to offset, updated to point past the header

\$data must really be a ScalarRef. Anything else is rejected before the data is looked at, with "data must be a ScalarRef: this method opens it, and a plain string is a filename, pass \$data rather than $data". That names the reason the check exists rather than describing what arrived: this method opens a filehandle on its first argument, and perl maps only an unblessed SCALAR ref into memory -- a plain string is a filename, so a caller who wrote parse($data, \$offset) without the backslash used to read the file that string names, and got a header parsed out of it rather than a type error. undef, a blessed scalar ref, a ref to a ref and every other shape are refused by the same check and get the same message. It quotes no part of the argument, since here that argument is either ciphertext or a path.

\$offset must be a ScalarRef too, for the opposite reason: it is an out-parameter. Nothing is read through it beyond the offset to start at -- the new offset is written back out through the same ref, which is how the caller learns where the payload begins, and only a ref carries a value back out of a call. Every other shape used to reach a raw dereference and be reported by perl as "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", each of them blaming a line in this module for a mistake made one frame up. They are now refused with "offset must be a ScalarRef: this method writes the new offset back through it, pass \$offset rather than $offset". As above, the clause after the colon carries the requirement and its reason rather than a description of what arrived, since one message answers all of those shapes; and it 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 settled before anything looks at the data, so a call that is malformed in its second argument is reported as that, and never first as a complaint about the first argument's contents.

The scalar \$data refers to must also be defined. A ScalarRef to an undefined scalar -- parse(\my $undef, \$offset) -- satisfies both shape checks above and used to reach the byte-string scan, the in-memory open under it and the readline in "parse_from_fh", raising ten "Use of uninitialized value" warnings carrying line numbers from inside this module before it croaked. It is now refused with "data must refer to a defined scalar: this method reads the age file out of it, assign the bytes before passing \$data".

That is deliberately not the same error as an empty or otherwise unparseable $data, which keeps the plain "Invalid age version: expected the literal age-encryption.org/v1 version line". A ref to nothing is a mistake in the call; bytes that are not an age file are a statement about data that really arrived, and the two have different fixes. Neither path warns.

Returns a Crypt::Age::Header object. The $offset is updated to point to the start of the payload.

Dies if the header format is invalid. That includes a malformed X25519 stanza: 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. Those are header failures and are raised here, before any identity is looked at, rather than being deferred to "unwrap_file_key" and mistaken there for a stanza that simply does not match the identity. See "BUILD" in Crypt::Age::Stanza::X25519.

A header carrying no recipient stanza at all is a header failure for the same reason and is raised in the same place. The format's grammar is header = v1-line 1*stanza end -- one or more -- so a version line followed directly by the --- footer is not a header, and letting it through used to surface one call later as "No matching identity found", which is the wrong cause for a file addressed to nobody. See "parse_from_fh" for the message.

Stanzas of unrecognized types are kept as plain Crypt::Age::Stanza objects and are not validated beyond the structure every stanza shares; the format requires them to be ignored, not rejected.

The scalar \$data refers to must hold bytes. One holding a code point above 0xFF cannot be mapped into a filehandle at all and is rejected before anything else happens, with "data must be a byte string: it holds a code point above 0xFF, read it with :raw rather than decoding it"; see "decrypt" in Crypt::Age for what this check does and does not catch.

Unlike the same check in "encrypt" in Crypt::Age, "decrypt" in Crypt::Age and "encrypt_payload" in Crypt::Age::Primitives, which downgrade a copy of the string they were passed, this one only reads $data: it is the caller's own scalar, not ours. On the success path that buys nothing observable -- perl's in-memory open downgrades the referenced scalar in place, so a $data stored upgraded whose code points all fit in a byte comes back downgraded whether this check is there or not. What it does guarantee is that a rejected $data is left exactly as the caller had it, and that the conversion on the success path stays perl's rather than this method's.

verify_mac

my $ok = $header->verify_mac($file_key);

Verifies that the header MAC is correct for the given file key.

Returns 1 if the MAC is valid, 0 otherwise. Used to confirm that the correct file key was unwrapped from a stanza.

The comparison goes through slow_eq from Crypt::Misc, so a wrong MAC is not rejected at the first differing byte. A MAC of the wrong length -- or no MAC at all -- returns 0; it is never fatal.

unwrap_file_key

my $file_key = $header->unwrap_file_key(\@identities);

Attempts to unwrap the file key using one or more identities.

Parameters:

  • \@identities - ArrayRef of Bech32-encoded secret keys (AGE-SECRET-KEY-1...)

\@identities must really be an ArrayRef; a single identity still goes in a list of one. That is the call which used to get this wrong, because one identity does not look like a list -- and here the check is a confidentiality one. A bare identity string reached a raw dereference and was reported by perl as "Can't use string (...) as an ARRAY ref while "strict refs" in use", where the (...) is the first 32 characters of the identity: secret key material, placed in an exception by this module, on its way into whatever log, bug report or terminal scrollback catches it, where the caller can no longer redact it. undef and other refs reached the same dereference and were reported as "Can't use an undefined value as an ARRAY reference" or "Not an ARRAY reference".

All of them are now refused, before any identity is looked at, with "identities must be an ArrayRef: this method tries each entry in turn, pass [$identity] rather than $identity", which carries the requirement and its reason and no byte of the argument.

Tries each identity against each stanza until one successfully unwraps the file key and verifies the MAC. Returns the 16-byte file key. Stanzas of other types are skipped, so a file that mixes recipient types still decrypts.

An undef entry in \@identities is skipped, silently and without warning, exactly as any other string that is not an AGE-SECRET-KEY-1 identity is: an identity that does not match is not an error here, only a list where none of them matches is. Unlike a recipient in "create", it is therefore not fatal on its own.

Dies if no matching identity is found or if MAC verification fails. It does not die for a structurally invalid X25519 stanza -- "parse" has already rejected the header by then -- but it does propagate the abort that a low-order-point ephemeral share triggers, since that is a header failure too and not a wrong identity.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-crypt-age/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <torsten@raudssus.de>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.