NAME
Crypt::Age::Primitives - Low-level cryptographic primitives for age encryption
VERSION
version 0.002
SYNOPSIS
use Crypt::Age::Primitives;
# Generate random file key
my $file_key = Crypt::Age::Primitives->generate_file_key();
# X25519 key exchange
my ($pub, $priv) = Crypt::Age::Primitives->x25519_generate_keypair();
my $secret = Crypt::Age::Primitives->x25519_shared_secret($our_priv, $their_pub);
# Key derivation and wrapping
my $wrap_key = Crypt::Age::Primitives->derive_wrap_key($secret, $eph_pub, $rec_pub);
my $wrapped = Crypt::Age::Primitives->wrap_file_key($wrap_key, $file_key);
my $unwrapped = Crypt::Age::Primitives->unwrap_file_key($wrap_key, $wrapped);
# Payload encryption
my $payload_key = Crypt::Age::Primitives->derive_payload_key($file_key);
my $encrypted = Crypt::Age::Primitives->encrypt_payload($payload_key, $plaintext);
my $decrypted = Crypt::Age::Primitives->decrypt_payload($payload_key, $encrypted);
# Header MAC
my $mac = Crypt::Age::Primitives->compute_header_mac($file_key, $header_bytes);
DESCRIPTION
This module provides low-level cryptographic primitives for age encryption. It wraps functions from CryptX and implements the age-specific key derivation and payload encryption schemes.
This is an internal module used by Crypt::Age. Most users should use the high-level interface provided by Crypt::Age instead.
Cryptographic Primitives Used
X25519 - Key exchange (Curve25519 Diffie-Hellman)
ChaCha20-Poly1305 - AEAD encryption
HKDF-SHA256 - Key derivation
HMAC-SHA256 - Header MAC
generate_file_key
my $file_key = Crypt::Age::Primitives->generate_file_key();
Generates a random 16-byte file key using a cryptographically secure PRNG.
The file key is used to encrypt the payload and is itself encrypted for each recipient.
x25519_generate_keypair
my ($public_bytes, $private_bytes) = Crypt::Age::Primitives->x25519_generate_keypair();
Generates a new X25519 keypair. Returns raw 32-byte public and private keys.
Note: For generating age-encoded keypairs, use "generate_keypair" in Crypt::Age::Keys instead.
x25519_shared_secret
my $shared_secret = Crypt::Age::Primitives->x25519_shared_secret($our_private, $their_public);
Performs X25519 key exchange to compute a shared secret.
Parameters are raw 32-byte keys. Returns a 32-byte shared secret.
Dies if the shared secret is all 0x00 bytes, which the age specification requires ("If the shared secret is all 0x00 bytes, the identity implementation MUST abort"). Such a secret means the peer key is a low-order point: on the decrypt path an attacker-supplied ephemeral share, on the encrypt path a recipient key that would yield a wrapping key known to everyone. The error message carries no key or secret material.
Note that this is a backstop: a sufficiently recent CryptX refuses the same peer keys one layer down and dies with its own message before this check is reached.
derive_wrap_key
my $wrap_key = Crypt::Age::Primitives->derive_wrap_key(
$shared_secret,
$ephemeral_public,
$recipient_public
);
Derives a wrapping key from an X25519 shared secret using HKDF-SHA256.
The salt is ephemeral_public || recipient_public (concatenated). The info string is "age-encryption.org/v1/X25519".
Returns a 32-byte key suitable for wrapping the file key.
wrap_file_key
my $wrapped_key = Crypt::Age::Primitives->wrap_file_key($wrap_key, $file_key);
Wraps a 16-byte file key using ChaCha20-Poly1305 with a zero nonce.
Returns a 32-byte value: 16 bytes ciphertext + 16 bytes authentication tag.
unwrap_file_key
my $file_key = Crypt::Age::Primitives->unwrap_file_key($wrap_key, $wrapped_key);
Unwraps a wrapped file key using ChaCha20-Poly1305.
Dies if authentication fails. Returns the 16-byte file key on success.
derive_payload_key
my $payload_key = Crypt::Age::Primitives->derive_payload_key($file_key, $nonce);
Derives a 32-byte payload encryption key from the file key and nonce using HKDF-SHA256.
The nonce (16 bytes) is used as the salt, and "payload" is the info string.
generate_payload_nonce
my $nonce = Crypt::Age::Primitives->generate_payload_nonce();
Generates a random 16-byte nonce for payload encryption.
compute_header_mac
my $mac = Crypt::Age::Primitives->compute_header_mac($file_key, $header_bytes);
Computes HMAC-SHA256 MAC over the header bytes.
First derives a MAC key from the file key using HKDF with info string "header", then computes HMAC-SHA256 of the header. Returns 32 bytes.
encrypt_payload
my $ciphertext = Crypt::Age::Primitives->encrypt_payload($payload_key, $plaintext);
Encrypts the payload using ChaCha20-Poly1305 in chunked mode.
The plaintext is split into 64 KiB chunks. Each chunk is encrypted with a unique nonce derived from a counter and a final-chunk flag. Returns the concatenated encrypted chunks.
encrypt_payload_fh
my $ciphertext = Crypt::Age::Primitives->encrypt_payload_fh($payload_key, $ifh, $ofh);
Encrypts the payload using ChaCha20-Poly1305 in chunked mode, using filehandles for both input and output.
The plaintext is split into 64 KiB chunks. Each chunk is encrypted with a unique nonce derived from a counter and a final-chunk flag. Returns the concatenated encrypted chunks.
decrypt_payload
my $plaintext = Crypt::Age::Primitives->decrypt_payload($payload_key, $ciphertext);
Decrypts a chunked payload encrypted with encrypt_payload.
Returns the decrypted plaintext. Dies on everything "decrypt_payload_fh" dies on -- a chunk that fails authentication, input ending without a final chunk, an empty final chunk that is not the whole payload, or data after the final chunk.
Unlike "decrypt_payload_fh" this method releases nothing on failure: the partial plaintext is written to an internal buffer that is discarded when the call dies, so a caller only ever sees plaintext from a payload that was decrypted to its final chunk. Callers that need the streaming behaviour, and can handle the partial release that comes with it, want the filehandle form.
decrypt_payload_fh
Crypt::Age::Primitives->decrypt_payload_fh($payload_key, $ifh, $ofh);
Decrypts a chunked payload encrypted with encrypt_payload, reading the ciphertext from $ifh and writing the plaintext to $ofh one chunk at a time. Returns nothing.
A chunk is final because it authenticates under the final-flag nonce, never because it happens to end the file. Dies if a chunk fails authentication under either nonce, if the input ends without a final chunk, if a final chunk other than the whole payload is empty, or if any byte follows the final chunk.
On failure, plaintext already written to $ofh stays there. Decryption is streaming, so every chunk that authenticated before the error was released to the output handle: a truncated file yields its intact prefix and then dies. Each released chunk is individually authenticated, but the message as a whole is not -- its completeness is exactly what the error says was never established. A caller must therefore discard whatever reached $ofh when this method dies, and must not treat it as an authenticated message merely because the individual bytes were authentic.
SEE ALSO
Crypt::Age - Main age encryption module
CryptX - Provides all cryptographic primitives
Crypt::PK::X25519 - X25519 key exchange
Crypt::AuthEnc::ChaCha20Poly1305 - AEAD encryption
Crypt::KeyDerivation - HKDF implementation
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.