NAME
Fugu::KeyDir - the names, the order and the generated text of a published key directory
SYNOPSIS
use Fugu::KeyDir;
my $dir = Fugu::KeyDir->new(org => 'fugubsd');
my $parts = $dir->parse_name('fugubsd-1-release.pub')
or die $dir->error;
# { stem => 'fugubsd-1-release', org => 'fugubsd',
# serial => 1, purpose => 'release', type => 'signify' }
my $serial = $dir->next_serial(\@names, 'release');
my $name = $dir->name_for(
serial => $serial,
purpose => 'release',
type => 'signify',
);
my @keys = (
{ name => 'fugubsd-1-release.pub', status => 'retired' },
{ name => 'fugubsd-2-release.pub', status => 'current',
since => '2026-09-06' },
);
$dir->check_statuses(\@keys) or die $dir->error;
my $rows = $dir->index_data(\@keys);
my $text = $dir->keys_file(\@keys);
DESCRIPTION
An organization publishes its public keys under one prefix. Fugu::KeyDir holds the generic parts of that key directory. It holds the file name pattern, the type of a key, and the status vocabulary. It also holds the order of a key set, and the text of the Apache KEYS file, of the human index, and of security.txt.
The module holds no policy. The organization word, each purpose, the contact and each date are arguments, so one tested implementation serves every site.
The module runs no command. It needs neither gpg(1) nor signify(1), and it renders no HTML: the site owns the template. Every recoverable failure returns undef, and error holds the reason.
The key name
A key file is <org>-<serial>-<purpose>.<ext>, for example fugubsd-1-release.pub.
The organization word holds lower-case letters and digits, and it starts on a letter. The serial is an integer with no padding, above zero, so a reader sorts it as a number. The purpose names what the key signs, and it holds lower-case letters, digits and hyphens. The extension names the type: pub is a signify key, and asc is an armored OpenPGP key.
The serial starts at 1 for each purpose, and each rotation of that purpose adds one. A new purpose therefore starts at 1, and a compromise of one purpose leaves the others in force.
A serial holds at most MAX_SERIAL_DIGITS digits. A serial counts the rotations of one purpose, so the bound is far above any real key set.
The bound also keeps every serial inside the exact integer range of each Perl build. A long run of digits becomes a float, and the threshold is the value and not the digit count.
parse_name stores the serial as a number, so a float would reach next_serial and then name_for, which rejects it. The rotation would stall. Nine digits sit far below the threshold of every build.
parse_name and name_for hold the same bound, so the two stay inverses.
The stem, such as fugubsd-1-release, is the key name in a description block and in a trust file. The name of a key never changes, and a retired key stays published at its URL, so a release that it signed still verifies.
The key set
Each method that reads a set takes an array reference of hash references. Each one must hold a name and a status. The module parses the name, so the serial and the purpose need no second source of truth.
A key can hold these optional fields, and each method passes them through:
armor-
The armored body of an OpenPGP key.
keys_fileneeds it. fingerprint-
The fingerprint of the key, from Fugu::OpenPGP.
since,until-
The dates of the key.
email-
The email address of an OpenPGP key.
The module never mutates a key that a caller passes. Each method returns new data.
new
new(org => $word) builds a key directory. The organization word is necessary.
new dies without the word, and for a word that the name pattern cannot hold. Each one is a programming error: a site build reads the word from its own description block.
org
org() returns the organization word of the directory.
error
error() returns the reason of the most recent failure. It returns undef after a success. Each method clears the reason before it starts.
parse_name
parse_name($filename) returns the parts of a key file name, as a hash reference with stem, org, serial, purpose and type.
The organization word must equal the word of the object. A name that names another organization is a failure, and the reason names both words.
Each of these is a failure:
an empty name, or a name with a solidus
a name with no extension
an upper-case extension, or an unknown one
a serial of zero, or a padded serial
a serial above the digit bound
a name that does not match the pattern
A name with a solidus is a path. A caller that passes a path would publish a key outside the directory.
name_for
name_for(serial => $n, purpose => $word, type => $type) returns the file name of a key. The method is the inverse of parse_name, so a caller never builds a name by hand.
A serial that is not an integer above zero, a purpose outside the pattern, and an unknown type are each a failure.
next_serial
next_serial(\@names, $purpose) returns the serial that a rotation of that purpose must take: one above the highest serial of the purpose. The answer is 1 when no key of the purpose exists.
@names holds key file names. A name that parse_name rejects is a failure, because a directory with an unreadable name has no highest serial. The method dies when @names is not an array reference.
order
order(\@keys) returns the keys in publication order, as a new array reference. Each entry holds the fields of the input key and the parts of its name.
The order is current, then next, then retired. Inside one status the serial descends, so the newest key of that status leads. The purpose then breaks a tie, and the name breaks the last one.
The order is total, so two runs write one byte sequence. A site build writes the index page on every build, so an unstable order would make a diff on each run.
An empty set, a status outside the vocabulary, and one name twice are each a failure. The method dies when the set is not an array reference, and when a key is not a hash reference.
check_statuses
check_statuses(\@keys) holds a key set to the status rule: each purpose must hold exactly one current key, and at most one next key.
The method returns 1 on a pass. On a failure it returns undef, and the reason names the purpose and the count. A purpose with two current keys is the dangerous case: a reader cannot tell which key signs a release today.
keys_file
keys_file(\@keys) returns the text of the Apache KEYS file.
The file holds each OpenPGP key of the set, in publication order, with a comment block in front of each armored body. gpg --import reads the file, and it skips a comment block.
The method skips a signify key: gpg(1) cannot read one, and a signify key file already publishes at its own URL. A set with no OpenPGP key therefore gives the empty string, and not a failure.
The empty string is false, so a caller must test defined and never truth. keys_file(...) or die $dir->error dies on a signify-only set, and error is undef there:
my $text = $dir->keys_file(\@keys);
die $dir->error unless defined $text;
The method writes one blank line after each armored body, whatever trailing newline the armor field held. Two runs therefore write one byte sequence.
gpg --import reads this file, so no caller field may forge a field or a block. Each of these is a failure:
an OpenPGP key with no
armorfielda
fingerprint,sinceoruntilvalue that holds a newline, because one line holds one fieldan
armorfield that holds more than one block, which would publish a second key under one namean
armorfield with text before its begin line or after its end line, which a reader takes for a comment block
index_data
index_data(\@keys) returns the data of the human page, as an array reference of hash references in publication order.
Each row holds name, stem, serial, purpose, type, status, fingerprint, since, until and email. An absent optional field is present and undef, so a template tests one thing and never two.
The method renders no HTML, because the site owns the template.
security_txt
security_txt(contact => $value, expires => $stamp, encryption => $url, languages => \@list) returns the text of security.txt, per RFC 9116.
contact and expires are necessary, and each one is a failure when absent. contact, encryption and languages each take one value or an array reference of values.
The RFC gives no order to the field types. It states only that the order of two Contact values carries the preference of the operator. This method therefore fixes one order of its own: Contact, Expires, each Encryption field, then Preferred-Languages. A fixed order makes two runs write one byte sequence, and it keeps the contact first for a human reader. Each list keeps the order that the caller named.
A field value that holds a newline is a failure. One line holds one field, so an embedded newline would forge a second field. A languages value that holds a comma is a failure for the same reason: the field joins its values on a comma.
An argument of the wrong reference type is a programming error, and the method dies. Without that test a reference would stringify into the file.
CONSTANTS
STATUSES-
The status vocabulary, as the list
current,next,retired. MAX_SERIAL_DIGITS-
The digit bound of a serial, 9.
RETURN VALUES
Each method returns its answer, or undef on a recoverable failure with the reason in error. check_statuses returns 1 on a pass.
A method dies for a programming error only: an argument of the wrong reference type, and an organization word that the pattern cannot hold.
CAVEATS
The module holds no key bytes. It never reads a key file, it computes no fingerprint, and it verifies nothing. Fugu::OpenPGP computes a fingerprint, and Fugu::Signify verifies a signature.
security_txt writes the fields that a caller names. It does not check that expires is a valid RFC 3339 stamp, and it does not check that the stamp is in the future.
SEE ALSO
gpg(1), signify(1), Fugu::OpenPGP, Fugu::Signify
AUTHORS
Dick Olsson <hi@senzilla.io>