Fugu

Fugu::KeyDir(3p) Perl Library Manual Fugu::KeyDir(3p)

Fugu::KeyDir - the names, the order and the generated text of a published key directory

    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;

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.

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.

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.

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(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() returns the organization word of the directory.

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($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(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($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(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(\@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(\@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(\@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(\@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) 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(\@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(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.

"STATUSES"
The status vocabulary, as the list "current", "next", "retired".
"MAX_SERIAL_DIGITS"
The digit bound of a serial, 9.

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.

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.

gpg(1), signify(1), Fugu::OpenPGP, Fugu::Signify

Dick Olsson <hi@senzilla.io>

2026-10-16 OpenBSD