NAME
Crypt::Age - Perl implementation of age encryption (age-encryption.org)
VERSION
version 0.003
SYNOPSIS
use Crypt::Age;
# Generate keypair
my ($public, $secret) = Crypt::Age->generate_keypair();
# $public = "age19ljhmg68..."
# $secret = "AGE-SECRET-KEY-1..."
# Encrypt data
my $encrypted = Crypt::Age->encrypt(
plaintext => "Hello, World!",
recipients => [$public],
);
# Decrypt data
my $decrypted = Crypt::Age->decrypt(
ciphertext => $encrypted,
identities => [$secret],
);
# Encrypt file
Crypt::Age->encrypt_file(
input => 'secret.txt',
output => 'secret.txt.age',
recipients => [$public],
);
# Decrypt file
Crypt::Age->decrypt_file(
input => 'secret.txt.age',
output => 'secret.txt',
identities => [$secret],
);
DESCRIPTION
Crypt::Age is a pure Perl implementation of the age encryption format, compatible with the reference Go implementation (https://github.com/FiloSottile/age) and the Rust implementation (https://github.com/str4d/rage).
age is a simple, modern and secure file encryption tool with small explicit keys, no config options, and UNIX-style composability. The format specification is available at https://github.com/C2SP/C2SP/blob/main/age.md.
This implementation uses X25519 for key exchange, ChaCha20-Poly1305 for authenticated encryption, and HKDF-SHA256 for key derivation. All cryptographic primitives are provided by CryptX.
Files encrypted with Crypt::Age can be decrypted with the age and rage command-line tools, and vice versa.
Two APIs are provided, and they differ in memory use. "encrypt" and "decrypt" take and return in-memory strings, so the whole plaintext or ciphertext has to fit in memory at once, both as the argument and as the returned value. "encrypt_file", "decrypt_file", "encrypt_filehandle" and "decrypt_filehandle" stream instead: they read and write in 64 KiB chunks, so memory use stays bounded regardless of how large the file is.
Every method here is byte-oriented: it encrypts and decrypts octets, never characters. The string API takes and returns byte strings; the file and filehandle API forces :raw on every handle it touches, which removes an :encoding layer the caller may have set on one. So encode a character string -- anything that has been through Encode::decode or arrived through an :encoding layer -- before you hand it over. "encrypt" and "decrypt" reject one outright when they can tell.
See "LIMITATIONS" below for what this module does not implement.
generate_keypair
my ($public_key, $secret_key) = Crypt::Age->generate_keypair();
Generates a new X25519 keypair for age encryption.
Returns a list of two elements:
$public_key- Bech32-encoded public key starting withage1$secret_key- Bech32-encoded secret key starting withAGE-SECRET-KEY-1
The public key can be shared with others to encrypt files for you. The secret key must be kept private and is used to decrypt files encrypted to your public key.
encrypt
my $ciphertext = Crypt::Age->encrypt(
plaintext => $data,
recipients => \@public_keys,
);
Encrypts plaintext data for one or more recipients.
Parameters:
plaintext- The data to encrypt (required)recipients- ArrayRef of Bech32-encoded public keys (required)
Returns the encrypted data in age format, which includes a text header followed by the encrypted payload. The file key is wrapped separately for each recipient, allowing any of them to decrypt the data.
recipients must really be an ArrayRef; a single recipient still goes in a list of one. Every other shape is refused before the file key is generated, with "recipients must be an ArrayRef: this method encrypts to every entry, pass [$recipient] rather than $recipient". As elsewhere in this distribution the clause after the colon carries the requirement and its reason rather than a description of what arrived, and quotes no part of the argument.
It must also be non-empty, and is refused with "recipients must not be empty: this method encrypts to every entry, so with none the result can never be decrypted, pass at least one recipient". That message replaces the older "at least one recipient required"; the check itself is unchanged and has always been there. Encrypting to nobody produces a file whose file key is wrapped for no one and therefore lost with the plaintext, and the age header grammar requires at least one recipient stanza in any case.
The returned data can be written to a file or transmitted directly.
plaintext and the returned ciphertext are both held in memory in full. For large data, use "encrypt_file" or "encrypt_filehandle", which stream in 64 KiB chunks instead.
plaintext must be a byte string. Encode a character string first:
use Encode qw( encode );
my $ciphertext = Crypt::Age->encrypt(
plaintext => encode('UTF-8', $characters),
recipients => \@public_keys,
);
A plaintext holding a code point above 0xFF cannot be encrypted at all and is rejected before anything else happens, with "plaintext must be a byte string: it holds a code point above 0xFF, encode it before passing it in".
A character string whose code points all happen to fit in a byte is not caught, because nothing distinguishes it from bytes: it is encrypted as those bytes, which is Latin-1, and "decrypt" hands Latin-1 back. Encoding explicitly is the only way to decide which bytes get encrypted.
decrypt
my $plaintext = Crypt::Age->decrypt(
ciphertext => $encrypted,
identities => \@secret_keys,
);
Decrypts age-encrypted data using one or more identities.
Parameters:
ciphertext- The age-encrypted data (required)identities- ArrayRef of Bech32-encoded secret keys (required)
Returns the decrypted plaintext.
identities must really be an ArrayRef; a single identity still goes in a list of one -- the likeliest way to get this wrong, since one identity does not look like a list. Every other shape is refused before the header is parsed, with "identities must be an ArrayRef: this method decrypts with whichever entry matches, pass [$identity] rather than $identity", which quotes no byte of the argument: a bare string in this parameter is a secret key.
It must also be non-empty, and is refused with "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". That message replaces the older "at least one identity required", so that an empty list reads the same way on both sides of the API; the check itself is unchanged.
The method tries each identity against each recipient stanza in the header until one successfully unwraps the file key. Dies on the same conditions as "decrypt_file", except file I/O errors -- this method never opens a file. It also rejects a ciphertext holding a code point above 0xFF before attempting any of that; see below for the exact message.
ciphertext must be a byte string, and so is the plaintext this method returns. age ciphertext is binary, so read it with :raw and never through an :encoding layer; decode the returned plaintext yourself if the message was text. A ciphertext holding a code point above 0xFF is rejected before anything else happens, with "ciphertext must be a byte string: it holds a code point above 0xFF, read it with :raw rather than decoding it".
ciphertext and the returned plaintext are both held in memory in full. For large data, use "decrypt_file" or "decrypt_filehandle", which stream in 64 KiB chunks instead. Because this method never returns a value on failure, a decryption that dies here does not expose any partial plaintext to the caller -- contrast "decrypt_filehandle", which writes to a caller-supplied handle and so can leave an authenticated-but-incomplete prefix behind.
encrypt_file
Crypt::Age->encrypt_file(
input => 'plaintext.txt',
output => 'encrypted.age',
recipients => \@public_keys,
);
Encrypts a file for one or more recipients.
Parameters:
input- Path to input file (required)output- Path to output file (required)recipients- ArrayRef of Bech32-encoded public keys (required)
The output file will be in age format and can be decrypted with the age or rage command-line tools.
Returns 1 on success. Dies if a required argument is missing, if recipients is not a non-empty ArrayRef -- "encrypt" quotes the two messages, one for the shape and one for the empty list -- if a recipient string is not a valid Bech32 age1... public key, if binmode fails on either handle, or on file I/O errors. Reads and writes the file in 64 KiB chunks, so memory use does not grow with the size of the file.
encrypt_filehandle
Crypt::Age->encrypt_filehandle(
input => \*STDIN,
output => \*STDOUT,
recipients => \@public_keys,
);
Encrypts for one or more recipients, based on filehandles for both input and output.
Parameters:
input- Input filehandle (required)output- Output filehandle (required)recipients- ArrayRef of Bech32-encoded public keys (required)
Both filehandles will be forced to be :raw using binmode. That removes every layer the caller had set, :encoding included, so what is encrypted is the bytes in input and never characters decoded from them. This method therefore needs no byte-string check of its own, unlike "encrypt": a handle delivers octets by the time it is read from here.
The output stream will be in age format and can be decrypted with the age or rage command-line tools.
Returns 1 on success. Dies if a required argument is missing, if recipients is not a non-empty ArrayRef -- "encrypt" quotes the two messages, one for the shape and one for the empty list -- if a recipient string is not a valid Bech32 age1... public key, or if binmode fails on either handle. Unlike "encrypt_file", this method never opens or closes a file itself -- input and output are handles the caller already has open -- so it cannot die with a "file not found" or "permission denied" error; that is the caller's concern before the handle is passed in. Streams in 64 KiB chunks, so memory use does not grow with the amount of data written.
decrypt_file
Crypt::Age->decrypt_file(
input => 'encrypted.age',
output => 'plaintext.txt',
identities => \@secret_keys,
);
Decrypts an age-encrypted file using one or more identities.
Parameters:
input- Path to encrypted input file (required)output- Path to decrypted output file (required)identities- ArrayRef of Bech32-encoded secret keys (required)
Returns 1 on success. Dies if a required argument is missing, if identities is not a non-empty ArrayRef -- "decrypt" quotes the two messages, one for the shape and one for the empty list -- if the header is invalid, if no identity matches any stanza, if the MAC verification fails, if payload authentication fails, if binmode fails on either handle, or on file I/O errors.
Reads the input and writes the output in 64 KiB chunks, so memory use does not grow with the size of the file. This means a failure does not undo what was already written: every chunk that authenticated before the error is already in output on disk once this method dies. Each such chunk is individually authentic, but the file as a whole is not -- that is exactly what the error reports. Treat a partial output as undecrypted and discard it; do not rely on the bytes that made it out. See "decrypt_payload_fh" in Crypt::Age::Primitives for the same guarantee stated at the primitive layer.
decrypt_filehandle
Crypt::Age->decrypt_filehandle(
input => \*STDIN,
output => \*STDOUT,
identities => \@secret_keys,
);
Decrypts age-encrypted data from a filehandle using one or more identities. Output is sent to a filehandle too.
Parameters:
input- Encrypted input filehandle (required)output- Decrypted output filehandle (required)identities- ArrayRef of Bech32-encoded secret keys (required)
Both filehandles will be forced to be :raw using binmode. That removes every layer the caller had set, :encoding included, so input is read as the binary it is and output receives plaintext bytes -- decode them yourself if the message was text.
Returns 1 on success. Dies if a required argument is missing, if identities is not a non-empty ArrayRef -- "decrypt" quotes the two messages, one for the shape and one for the empty list -- if the header is invalid, if no identity matches any stanza, if the MAC verification fails, if payload authentication fails, or if binmode fails on either handle. Unlike "decrypt_file", this method never opens or closes a file itself -- input and output are handles the caller already has open -- so it cannot die with a "file not found" or "permission denied" error; that is the caller's concern before the handle is passed in.
Decryption streams: plaintext is written to output one 64 KiB chunk at a time as each chunk authenticates, so memory use does not grow with the amount of data decrypted. This also means a failure does not undo what was already written. If the payload is truncated or corrupt, every chunk that authenticated before the error is already in output when this method dies; each of those chunks is individually authentic, but the message as a whole is not, which is exactly what the error reports. A caller must treat whatever reached output as unauthenticated and discard it, rather than as a decrypted message merely because its individual bytes checked out. See "decrypt_payload_fh" in Crypt::Age::Primitives for the same guarantee stated at the primitive layer.
KEY FORMAT
Public Keys
Public keys are Bech32-encoded X25519 public keys with the human-readable part age:
age19ljhmg68e43yx9fgm2k9lwefquc0la5y4lzvlshdjzv47kxt8d6qr9vf4p
Secret Keys
Secret keys are uppercase Bech32-encoded X25519 secret keys with the human-readable part AGE-SECRET-KEY-:
AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ8H00W3
INTEROPERABILITY
This module is designed to be compatible with:
https://github.com/FiloSottile/age - Reference Go implementation
https://github.com/str4d/rage - Rust implementation
Files encrypted with Crypt::Age can be decrypted with these tools and vice versa.
LIMITATIONS
Only the X25519 recipient type is implemented, and it is complete in both directions: keypair generation, header creation and parsing, wrapping and unwrapping, and the header MAC. Not implemented:
scrypt (passphrase) recipients
SSH recipients
the post-quantum and tagged recipient types (
mlkem768x25519and similar)ASCII armor
A stanza of one of these types is not rejected outright: the format requires unrecognized stanza types to be ignored, and this implementation does that (see "parse_from_fh" in Crypt::Age::Header), so a file with both an X25519 recipient and, say, a scrypt one still decrypts normally for an identity that matches the X25519 stanza. But a file whose recipients are all of these unsupported types dies: "decrypt" and the other decrypt methods raise "No matching identity found", since "unwrap_file_key" in Crypt::Age::Header only tries stanzas it recognizes as X25519. An armored file fails even earlier, because its first line is not the literal age-encryption.org/v1 version line that "parse_from_fh" in Crypt::Age::Header requires. On the encrypt side, passing a recipient string that is not a Bech32 age1... public key dies with "Unsupported recipient format at index N: expected an age1 recipient", N being that recipient's position in the recipients array. A suffix names what arrived instead wherever that can be said without quoting it: a string that looks like a secret key adds ", got an AGE-SECRET-KEY-1 identity", and an undef entry adds ", got undef". The rejected string itself is never quoted back: it may be a secret key passed where a recipient belongs, and the message would carry it into the caller's logs. See "create" in Crypt::Age::Header.
SECURITY
age uses modern cryptographic primitives:
X25519 for key agreement (Curve25519 Diffie-Hellman)
ChaCha20-Poly1305 for authenticated encryption
HKDF-SHA256 for key derivation
The file key is randomly generated for each encryption operation. The payload is encrypted in 64 KiB chunks with unique nonces derived from a counter and final-chunk flag.
SEE ALSO
https://age-encryption.org - age encryption homepage
https://github.com/C2SP/C2SP/blob/main/age.md - age format specification
CryptX - Cryptographic toolkit providing all primitives
Crypt::Age::Header - Header parsing, generation and the header MAC
Crypt::Age::Stanza - Base recipient stanza class
Crypt::Age::Stanza::X25519 - X25519 recipient stanza
Crypt::Age::Keys - Key generation and encoding
Crypt::Age::Primitives - Low-level cryptographic operations
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.