NAME

Crypt::Age::Primitives - Low-level cryptographic primitives for age encryption

VERSION

version 0.003

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 $nonce = Crypt::Age::Primitives->generate_payload_nonce();
my $payload_key = Crypt::Age::Primitives->derive_payload_key($file_key, $nonce);
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.

$plaintext must be a byte string. One holding a code point above 0xFF 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"; see "encrypt" in Crypt::Age for what this check does and does not catch.

encrypt_payload_fh

Crypt::Age::Primitives->encrypt_payload_fh($payload_key, $ifh, $ofh);

Encrypts the payload using ChaCha20-Poly1305 in chunked mode, reading the plaintext from $ifh and writing the ciphertext to $ofh one chunk at a time. Returns nothing -- unlike "encrypt_payload", the encrypted bytes are never assembled in memory, only written to $ofh as each chunk is produced.

Input is read via "paranoid_read" in 64 KiB chunks; a chunk is final when that read leaves the input handle at eof. Each chunk is encrypted with a nonce derived from a counter and that final-chunk flag.

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.

$ciphertext must be a byte string. One 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"; see "decrypt" in Crypt::Age.

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.

paranoid_read

my $bytes = Crypt::Age::Primitives->paranoid_read($fh, $length);

Reads up to $length bytes from $fh, retrying a zero-byte read that is not eof -- as a pipe or socket can produce -- instead of treating it as the end of the data. Internal; used throughout this module and by Crypt::Age's streaming paths wherever a caller must not mistake a stalled read for a short file.

A read that hits eof before $length bytes have accumulated is not an error: this is how the chunked STREAM readers and Crypt::Age's payload nonce read learn that the input ends there, so the return value can be shorter than $length. It is the caller's job to decide whether that short length is expected (as encrypt_payload_fh's last chunk) or a truncated file (as decrypt_payload_fh and Crypt::Age's 16-byte nonce read treat it). This method dies only when three consecutive reads return zero bytes without reaching eof.

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.