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-root.pub',    status => 'current' },
    { 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);

my $binding = $dir->binding_for(
    target => 'fugubsd-1-root.pub',
    signer => 'fugubsd-2-release.pub',
);
# fugubsd-1-root.pub.fugubsd-2-release.sig

$dir->check_bindings(\@keys, [$binding], 'fugubsd-1-root.pub')
    or die $dir->error;

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 also holds the name of a binding, and the retention rule of a set of bindings. A binding is the signature of one key file by another key.

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, asc is an armored OpenPGP key, and pem is an X.509 certificate.

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 binding

A binding is the signature of one key file by another key of the directory. The file name is <target file>.<signer stem>.<ext>, for example fugubsd-1-root.pub.fugubsd-2-release.sig.

The extension names the type of the signer: sig is a signify signature, and asc is an armored OpenPGP signature. A p7s binding is a detached CMS signature of an X.509 signer. A later signer type adds its own extension.

The key file of the signer is its stem with the key extension of that type. A sig binding therefore names a .pub key, an asc binding names a .asc key, and a p7s binding names a .pem key. parse_binding answers that key file name, so the module holds the one extension table.

The retention rule holds each binding of a directory. A signer that is current or next targets the root key, so each key in force attests the one anchor. A signer that is retired targets a key of its own purpose with a higher serial, so a holder of the old key verifies the new one.

The caller names the root key, because the module holds no purpose.

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_file needs 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.

parse_binding

parse_binding($filename) returns the parts of a binding file name, as a hash reference with target, signer and type.

The target and the signer are each a key file name, and each one parses under parse_name. The type names the type of the signer. The name of a binding holds the stem of the signer, and the method completes it with the key extension of the type. The two parts therefore feed binding_for, which names the same file again.

Each of these is a failure:

  • an empty name, or a name with a solidus

  • a name that does not match the pattern

  • an unknown binding extension

  • a target that parse_name rejects

  • a completed signer name that parse_name rejects

The reason of the last two holds the reason of parse_name.

binding_for

binding_for(target => $name, signer => $name) returns the file name of a binding. Each argument is a key file name. The method is the inverse of parse_binding: the target and the signer of a parse name the same file again, so a caller never builds a name by hand.

The type of the signer selects the extension of the binding. A target or a signer that parse_name rejects is a failure, and a key type that signs no binding is 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.

check_bindings

check_bindings(\@keys, \@bindings, $root) holds each binding to the retention rule. @bindings holds binding file names, and $root is the file name of the current root key.

A signer that is current or next must target the root. A signer that is retired must target a key of its own purpose with a higher serial.

The method returns 1 on a pass. On a failure it returns undef, and the reason names the binding and the fault.

Each of these is a failure:

  • a root that the key set does not hold

  • a binding name that parse_binding rejects

  • a signer or a target that the key set does not hold

  • a binding that breaks the retention rule

The method dies when @bindings is not an array reference, and for each programming error of order.

An empty @bindings list passes: the method holds the bindings that a caller names, and it needs no binding for a key.

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 armor field

  • a fingerprint, since or until value that holds a newline, because one line holds one field

  • an armor field that holds more than one block, which would publish a second key under one name

  • an armor field 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.

check_bindings reads names alone. It opens no binding file, and it verifies no signature: Fugu::Signify verifies a signify 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>