FuguWeb

App::FuguWeb::Keys(3p) Perl Library Manual App::FuguWeb::Keys(3p)

App::FuguWeb::Keys - the key directory of a site

    use App::FuguWeb::Keys;
    my $keys = App::FuguWeb::Keys->new(config => $config, dir => 'keys');
    my @paths     = $keys->paths;
    my $generated = $keys->generated;
    my @problems  = $keys->problems;
    my ($files, $why) = App::FuguWeb::Keys->site_generated($config);

An organization publishes its public keys under one prefix, so a consumer install can fetch a key and verify a release with it. This class is the wiring of that directory. It reads the description blocks and calls the Fugu modules. It answers with paths, with bytes, and with the problems of a check.

Every generic part lives in Fugu. Fugu::KeyDir holds the name pattern of a key and of a binding, the publication order, the retention rule, and the text of the "KEYS" file and of security.txt. Fugu::OpenPGP decodes an armored key and computes a fingerprint and a Web Key Directory hash. Fugu::X509 decodes a PEM certificate and reads its fingerprint, its subject and its validity. Fugu::Signify parses the manifest. Nothing generic lives here.

A site build neither signs nor verifies, so the manifest pair is a source file and not a generated one. The check verifies each binding, because a binding that no key made is worth nothing to a consumer. A signify binding needs no command, and a binding of another type runs the command of its type. An absent command is a problem of the host, and the check reports it rather than pass a binding that nothing read.

One object reads one key directory. A description can name several, and each one holds its own root of trust, per D-02. A caller reads "$config->keys_dirs" first, and this class dies for a name that no block holds.

Each "keys" block names one key directory. The name is one segment below the source directory. The block also names the organization word:

    keys "keys" {
        org     = fugubsd
        contact = mailto:security@fugubsd.org
        expires = 2027-09-07T00:00:00Z
        url     = https://www.fugubsd.org/keys
    }
    key "fugubsd-1-release" {
        status = current
        since  = 2026-09-07
    }

The block name of a "key" block is the stem of the key file. The extension of the file on disk decides the type: ".pub" is a signify key, ".asc" is an armored OpenPGP key, and ".pem" is one X.509 certificate. The status is "current", "next" or "retired".

App::FuguWeb::Config reads and validates both blocks. It resolves each "key" block to its file, so this class repeats no name pattern. The directory that holds the file owns the block.

A description holds as many "keys" blocks as the site publishes directories. Two blocks must not name one directory, and one block alone names the "contact" and the "expires" of the site.

    keys/<stem>.pub          each key file, byte for byte
    keys/<stem>.asc
    keys/<stem>.pem
    keys/<key>.<stem>.sig    each binding, byte for byte
    keys/<key>.<stem>.asc
    keys/<key>.<stem>.p7s
    keys/SHA256              the manifest pair, copied as it stands
    keys/SHA256.sig
    keys/KEYS                every OpenPGP key, in the Apache form
    keys/index.html          the human page
    .well-known/openpgpkey/hu/<hash>    the Web Key Directory
    .well-known/openpgpkey/policy
    .well-known/security.txt            RFC 9116

The human page is a page of the default language. Its chrome links the tree of "en" as ../en/<page>, it links the stylesheet as ../style.css, and it carries no footer.

The two well-known paths sit at the site root, because a reader asks for the registered path and never for one below the key directory. The site holds one such tree, and every key directory writes into it.

One Web Key Directory file holds every key of one address, in publication order, across every directory. A rotation gives one address a current key and a next key, and a reader takes the whole file.

An address whose keys are all "retired" serves no file. "gpg --locate-keys" reads that file to encrypt a message, and a retired key is the one key that must not answer it. The rule reads the address of the site, so a "current" key of one directory keeps the address of another directory in service.

    App::FuguWeb::Keys->new(config => $config, dir => 'keys')

"config" is an App::FuguWeb::Config, and "dir" is the name of one key directory of it. Both are required, and the method dies for a name that no "keys" block holds.

    $keys->error

The reason of the most recent failure, or "undef" after a success.

    $keys->paths

Every path that this key directory adds to the output, relative to the output directory. The method reads no file: the description and the file names decide the list.

Two directories that serve one address name one path there, so "key_paths" in App::FuguWeb::Config holds each path once.

    $keys->copies

Every file that the build copies as it stands, as a list of hash references with "from" and "to".

    $keys->generated

Every file that the build writes itself for this directory, as a hash reference of output path to bytes. The method returns "undef" on a failure, and "error" holds the reason.

The Web Key Directory files of the site are not among them. One address can hold a key of every directory, so "site_generated" writes that tree.

    App::FuguWeb::Keys->site_generated($config)

Every file that the build writes itself for the key directories of the site, as a hash reference of output path to bytes. The method answers that reference, or "undef" with the reason behind it.

The Web Key Directory file of one address gathers the keys of that address across every directory, in publication order, so the build writes one file where two directories name one address. The order comes from the keys, and never from the directory that holds them: a "current" key of one directory leads a "retired" key of the other.

An address serves no file when every key of it is "retired", in every directory of the site.

    $keys->key_set

The key set for Fugu::KeyDir: every key block of this directory, with the bytes of each key that a later read needs. An OpenPGP key carries its armored body, and a certificate the DER that its PEM block holds. The decoder of each type runs here, so a file that holds no key of its type fails before the build copies one.

    $keys->problems
    $keys->problems( expiry => 0 )

What the key directory of the checkout is not true of, each one a sentence that names the file. An empty list means the directory is good.

The checks read the source directory and not the output. A stray file and a stale digest are faults of the checkout, so the answer must not depend on a build having run.

"expiry" 0 leaves out the validity rules of an OpenPGP key and of a certificate. The clock decides those, and no step of App::FuguWeb::Rotate can move a date that a key carries. A step reads its own work back with this argument.

An expired "current" key is still "current" at that read, so a step of its purpose could never be made. The 30-day report reads the whole directory, so it would fail a step of another purpose, which writes no "next" key of the purpose that the report names.

    App::FuguWeb::Keys->shaped($config, $path)

Whether a path of the output is one that a build of a key directory writes. The method reads each declared directory. The set is bounded: a generated name of a key directory, a key file or a binding file that Fugu::KeyDir parses, and the three well-known paths.

The prune and the clean read this one answer, so a build can never remove a file that the clean refuses. A stale key file needs it: the inventory names what the site holds today, so it cannot answer for a key that the description dropped.

A description with no "keys" block owns no path at all. A site of another maker holds .well-known/security.txt too, and a clean that took it would delete that site.

  • Every key file has a block, and every block has a file. A binding needs no block, and its target and its signer are each a key of the same directory: a binding never crosses a directory, per D-02.
  • Every name matches the key pattern or the binding pattern of Fugu::KeyDir.
  • Every purpose holds exactly one "current" key.
  • The directory holds one "current" signify key of the purpose "root", which is the root of trust.
  • Each "current" key and each "next" key of a subordinate purpose holds a binding over the current root key.
  • Each binding verifies against the public key of its signer, and each one holds the retention rule of Fugu::KeyDir.
  • "SHA256" names every key file and every binding file, with the digest that the file has, and it names nothing else.
  • The block that names the contact holds a "current" OpenPGP key, when it names a "url" and another key directory holds one. security.txt would otherwise carry no "Encryption" field, and the field must not name a key of another directory.
  • The declared fingerprint of an OpenPGP key and of a certificate equals the computed one. The fingerprint of a certificate is the SHA-256 of its DER form.
  • No "current" or "next" key has an expiry that passed, and no certificate of that status waits for its "notBefore". A "current" key that expires within 30 days is a problem when its purpose holds no "next" key, because one rotation runs in two steps. The expiry of an OpenPGP key comes from gpg(1), and an absent command is a problem of the host. The dates of a certificate come from its own bytes. A signify key carries no date, and an OpenPGP key with no expiry reaches no rule.
  • "SHA256.sig" takes the shape of a signify signature. It holds two lines: an "untrusted comment: " line, and 100 base64 characters. Those characters decode to 74 bytes, and the first two spell "Ed".

The class verifies no "SHA256.sig". That is the work of a consumer install: the site build cannot sign, so a site that verified its own manifest would prove nothing. It reads the shape of the signature because a file of another shape fails at every consumer install.

App::FuguWeb, App::FuguWeb::Config, App::FuguWeb::Check, App::FuguWeb::Site, Fugu::KeyDir, Fugu::OpenPGP, Fugu::Signify, Fugu::X509

Dick Olsson <hi@senzilla.io>

2026-10-21 OpenBSD