NAME

Crypt::Age::Keys - Key generation and Bech32 encoding for age encryption

VERSION

version 0.003

SYNOPSIS

use Crypt::Age::Keys;

# Generate keypair
my ($public, $secret) = Crypt::Age::Keys->generate_keypair();

# Encode/decode public keys
my $encoded_public = Crypt::Age::Keys->encode_public_key($public_bytes);
my $public_bytes = Crypt::Age::Keys->decode_public_key('age1...');

# Encode/decode secret keys
my $encoded_secret = Crypt::Age::Keys->encode_secret_key($secret_bytes);
my $secret_bytes = Crypt::Age::Keys->decode_secret_key('AGE-SECRET-KEY-1...');

# Derive public key from secret key
my $public = Crypt::Age::Keys->public_key_from_secret($secret);

DESCRIPTION

This module provides key generation and Bech32 encoding/decoding for age encryption.

age uses X25519 (Curve25519 Diffie-Hellman) for key agreement. Keys are encoded using Bech32, the same encoding used for Bitcoin SegWit addresses (BIP-173).

Public keys use the human-readable part age and are lowercase. Secret keys use the human-readable part age-secret-key- and are uppercase.

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

generate_keypair

my ($public_key, $secret_key) = Crypt::Age::Keys->generate_keypair();

Generates a new X25519 keypair.

Returns a list of two Bech32-encoded strings:

  • $public_key - Starts with age1, lowercase

  • $secret_key - Starts with AGE-SECRET-KEY-1, uppercase

encode_public_key

my $encoded = Crypt::Age::Keys->encode_public_key($public_bytes);

Encodes a 32-byte X25519 public key as a Bech32 string with HRP age.

Returns a lowercase string starting with age1.

decode_public_key

my $public_bytes = Crypt::Age::Keys->decode_public_key('age1...');

Decodes a Bech32-encoded age public key to raw bytes.

Dies if the HRP is not age, if the decoded data is not 32 bytes, or if the string mixes upper- and lowercase; see "bech32_decode". The HRP is compared case-insensitively, so an all-uppercase AGE1... key is accepted as well.

The HRP mismatch is reported as "Invalid public key HRP: expected the literal age prefix, pass an age recipient rather than an identity or some other Bech32 string". It names the expected HRP, which is a constant of the format, and not the one that arrived: the received HRP is everything before the last 1 of the string that was passed in, so it is a prefix of the caller's own material. Here that material is a public key and no secret is at stake, but the message reads the same as "decode_secret_key"'s, where it is. Callers matching on the old "expected 'age', got '...'" wording see the new message instead.

encode_secret_key

my $encoded = Crypt::Age::Keys->encode_secret_key($secret_bytes);

Encodes a 32-byte X25519 secret key as a Bech32 string with HRP age-secret-key-.

Returns an uppercase string starting with AGE-SECRET-KEY-1.

decode_secret_key

my $secret_bytes = Crypt::Age::Keys->decode_secret_key('AGE-SECRET-KEY-1...');

Decodes a Bech32-encoded age secret key to raw bytes.

Dies if the HRP is not age-secret-key-, if the decoded data is not 32 bytes, or if the string mixes upper- and lowercase; see "bech32_decode". The HRP is compared case-insensitively, so an all-lowercase age-secret-key-1... key is accepted as well as the uppercase form "encode_secret_key" emits.

The HRP mismatch is reported as "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 quotes no part of what arrived. The received HRP is everything before the last 1 of the caller's string, so a string whose HRP is the opening characters of a real identity would have had those characters written into an exception raised inside this module, where the caller can no longer redact them.

Reaching that croak needs a Bech32 string whose checksum verifies over the wrong HRP, so it is a constructed input rather than a mistyped one: a key truncated anywhere dies first with "Invalid bech32: no separator", "Invalid bech32: empty data" or "Invalid bech32 checksum", and one with trailing junk with "Invalid bech32 checksum", none of which quote anything either. The everyday way to reach it -- passing a public key here, or an identity to "decode_public_key" -- puts only the other type's prefix in that position. Callers matching on the old "expected 'age-secret-key-', got '...'" wording see the new message instead.

public_key_from_secret

my $public_key = Crypt::Age::Keys->public_key_from_secret($secret_key);

Derives the public key from a secret key.

Takes a Bech32-encoded secret key and returns the corresponding Bech32-encoded public key. This is useful for when you have a secret key and need to know what public key it corresponds to.

IMPLEMENTATION NOTES

bech32_polymod, bech32_hrp_expand, bech32_create_checksum and bech32_verify_checksum below implement the checksum algorithm from BIP-173. They are called only by "bech32_encode" and "bech32_decode" in this same class, as plain functions rather than through the $class->method(...) convention the rest of this module uses, and are not documented individually here.

bech32_encode

my $encoded = Crypt::Age::Keys->bech32_encode($hrp, $bytes);

Encodes $bytes as Bech32 (BIP-173) with the given human-readable part $hrp: converts the bytes from 8-bit to 5-bit groups, computes the checksum, and joins $hrp, the 1 separator, the data and the checksum through the Bech32 charset.

This is the generic codec "encode_public_key" and "encode_secret_key" call; most callers want those instead, since they also know the age HRPs and enforce the 32-byte key length that this method does not.

bech32_decode

my ($hrp, $bytes) = Crypt::Age::Keys->bech32_decode($encoded);

Decodes a Bech32 (BIP-173) string, verifying its checksum. Returns the human-readable part exactly as it appeared in $encoded (not lowercased) and the decoded data as raw bytes.

This is the generic codec "decode_public_key" and "decode_secret_key" call; most callers want those instead, since they also check the HRP and the decoded length.

Dies if there is no 1 separator, if the data part is empty, if it contains a character outside the Bech32 charset, or if the checksum does not verify.

The charset failure names the position rather than the character: Invalid bech32 character at offset N, where N is a 0-based offset into the string that was passed in -- not into the data part after the separator -- so substr($encoded, $N, 1) is the character it rejected. Withholding the character is deliberate. Anything a caller passes reaches here, and a string that is not an age key at all -- a passphrase handed to this method by mistake -- would otherwise have one of its own bytes quoted back in an exception that tends to end up in a log. No byte of an actual key is at stake either way: every character of an encoded key is inside the charset, so none of them can reach this failure.

BIP-173 also requires an encoding to be entirely uppercase or entirely lowercase, and this method enforces that: a string mixing the two dies with Invalid bech32: mixed case before the separator is even looked for. An all-uppercase and an all-lowercase string are both accepted, and decode to the same bytes -- the checksum is verified against the lowercased HRP, since BIP-173 defines it over the lowercase form regardless of how the string is written.

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.