NAME
App::FuguWeb::Keys - the key directory of a site
SYNOPSIS
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);
DESCRIPTION
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.
THE DESCRIPTION
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.
THE PUBLISHED TREE
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 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.
METHODS
new
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.
error
$keys->error
The reason of the most recent failure, or undef after a success.
paths
$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.
copies
$keys->copies
Every file that the build copies as it stands, as a list of hash references with from and to.
generated
$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.
site_generated
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.
key_set
$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.
problems
$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.
shaped
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.
THE CHECKS
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
currentkey.The directory holds one
currentsignify key of the purposeroot, which is the root of trust.Each
currentkey and eachnextkey 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.
SHA256names 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
currentOpenPGP key, when it names aurland another key directory holds one. security.txt would otherwise carry noEncryptionfield, 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
currentornextkey has an expiry that passed, and no certificate of that status waits for itsnotBefore. Acurrentkey that expires within 30 days is a problem when its purpose holds nonextkey, because one rotation runs in two steps. The expiry of an OpenPGP key comes fromgpg(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.sigtakes the shape of a signify signature. It holds two lines: anuntrusted comment:line, and 100 base64 characters. Those characters decode to 74 bytes, and the first two spellEd.
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.
SEE ALSO
App::FuguWeb, App::FuguWeb::Config, App::FuguWeb::Check, App::FuguWeb::Site, Fugu::KeyDir, Fugu::OpenPGP, Fugu::Signify, Fugu::X509
AUTHOR
Dick Olsson <hi@senzilla.io>