FuguWeb

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

App::FuguWeb::Rotate - write the key directory of a site

    use App::FuguWeb::Rotate;
    my $rotate = App::FuguWeb::Rotate->new(
        config => $config,
        org    => 'fugubsd',
        url    => 'https://www.fugubsd.org/keys',
    );
    # The first root mint. No root stands, so the new key signs
    # its own manifest and each key in force binds to it.
    my $facts = $rotate->mint(
        purpose => 'root',
        secret  => '/tmp/root-1.sec',
        bind    => { 'fugubsd-1-release' => '/tmp/release-1.sec' },
    ) or die $rotate->error;
    # A subordinate mint. The current root signs the manifest,
    # and the new key binds to that root.
    $facts = $rotate->mint(
        purpose => 'release',
        secret  => '/tmp/release-2.sec',
        signer  => '/tmp/root-1.sec',
    ) or die $rotate->error;
    # An OpenPGP mint. gpg(1) makes the key and the subkey, and
    # the email is the one user id.
    $facts = $rotate->mint(
        purpose => 'contact',
        type    => 'openpgp',
        email   => 'security@fugubsd.org',
        expires => '2028-01-01',
        secret  => '/tmp/contact-1.sec',
        signer  => '/tmp/root-1.sec',
    ) or die $rotate->error;
    # An import. An issuer makes a certificate, so no verb mints
    # one, and the private key of the publisher signs its binding.
    $facts = $rotate->import_key(
        purpose => 'sign',
        type    => 'x509',
        file    => '/tmp/sign-1.pem',
        secret  => '/tmp/sign-1.key',
        signer  => '/tmp/root-1.sec',
    ) or die $rotate->error;
    # A subordinate promote. The retiring key signs the key that
    # takes its place.
    $facts = $rotate->promote(
        purpose  => 'release',
        signer   => '/tmp/root-1.sec',
        retiring => '/tmp/release-1.sec',
    ) or die $rotate->error;

App::FuguWeb::Keys reads the key directory, and this class writes it. One class holds every step, so the reader and the writer share one implementation of the name pattern, the status vocabulary and the manifest.

One signify key of the directory is the root of trust, and its purpose word is "root". The current root signs the manifest, and every other key of the directory binds to it with a signature of its own. A consumer that pins the root can therefore trust every key beside it.

A consumer verifies a release against the copy of the public key that it holds. A release that a new key signs therefore fails in each consumer that holds the old copy. One rotation runs in two steps, and the trust order carries the gap.

"mint" makes the next key of a purpose, and "import_key" publishes a key that another tool made. "promote" makes that key current and retires the old one. The first key of a purpose is current at once.

A mint makes a signify pair or an OpenPGP key. An OpenPGP key is one Ed25519 primary key with one Curve25519 encryption subkey, and the email of the caller is its one user id.

An issuer makes an X.509 certificate, so no verb mints one. The import publishes it, and the unencrypted PEM private key of the publisher signs its binding. A renewal is a rotation: the new certificate enters as the "next" key, and a promote retires the old one.

This class signs, and a site build does not. It runs no command of its own: Fugu::Signify holds every call of signify(1), Fugu::OpenPGP holds every call of gpg(1), and the class of each other key type holds every call of its own command.

A binding is a detached signature by one key over the public key file of another key. Its file name is "<target file>.<signer stem>.<ext>", and Fugu::KeyDir holds that name and the retention rule.

The type of the signer key selects the signer and the extension. A signify key signs with signify(1), an OpenPGP key signs with gpg(1), and a certificate signs with openssl(1). A PEM private key names no certificate, so the signer of that type reads the published certificate of the key beside the private half.

Each "current" and "next" key of a subordinate purpose holds one binding over the public key file of the current root. That signature proves that the holder of the root also holds the subordinate key.

A promote writes a chain binding: the retiring key signs the public key file of the key that takes its place. A consumer that trusts the old key therefore reaches the new one. The chain binding stays published, as the retired key does, and the promote removes the root binding of the retired key.

A first root mint and a root promote write the binding of each key in force again, because the root that those keys attest changes. The step takes the private half of each such key in "bind", and it refuses before it writes when one is absent. A root promote also removes each binding of the retired root, less a chain binding.

Each rule below answers a way to publish a key directory that no consumer can use.

The status of a key comes from its "key" block, and no step assumes one. A key file with no block, and a block with no file, each fail the step. A step that read the first key of a purpose would take a retired key for the current one.
Every step but two takes the private half of the current root as "signer". A first root mint takes none, because no root stands and the new key signs its own manifest. A root promote takes none, because the key that it makes current signs.
Each step stages the manifest, each binding and each signature in a temporary directory. It verifies each signature against the published public key of the signer, and only a verified file reaches the key directory.
A mint refuses a purpose that holds a "next" key already, and it refuses before it generates a pair. A caller holds one place for the private half of a mint, so a second key would lose the private half of the first.
Every byte of a step is ready before the first write. Each file lands through a temporary file in the same directory, so a reader sees the old bytes or the new ones. A write that fails, and a check that fails after the writes, both put every file back.

A binding that a step retires goes after every write. A run that stops between the two therefore leaves a file that the manifest does not name, and never a manifest that names a file which is gone.

The private half of a new key lands last, after the check passes. A step that fails therefore leaves no key that the site does not publish.

A process that dies inside a step leaves the checkout dirty. A caller runs a step and commits after it, so it commits nothing then.

One manifest covers the whole directory, and the current root signs it. A step of another purpose fails when the directory holds no current root. A directory whose keys hold no root is the state of a site before its first root mint, and that one step takes it.
Each step ends with App::FuguWeb::Keys. A directory that the reader rejects is a directory that "fuguweb check" rejects, so the step fails and the caller commits nothing.

The rotation over one key directory of one description. "config" is an App::FuguWeb::Config.

"dir" names the directory that each step writes. A description with one "keys" block needs none, and a description with several refuses a step without it. Each directory holds its own root of trust, per D-02, so a step of one directory reads no key of another.

A directory that publishes its first key holds no "keys" block, because such a block cannot load until a "key" block stands beside it. The caller then names "bootstrap" and "org", and it can name "dir" and "url". One commit carries the "keys" block, the first "key" block and the key itself. A second key directory of a site arrives the same way, and it takes "dir".

"bootstrap" is the intent to make that directory. A step without it refuses a "dir" that no "keys" block names, because a mistyped word would otherwise publish a second root of trust in silence. "mint" and "import_key" make a directory, and "promote" makes none.

$rotate->mint(purpose => $word, ...)
Generate the next key of the purpose. The public half goes into the key directory, and the private half goes to "secret" with no group mode and no other mode.

"type" names the key type. The default is "signify", and "openpgp" is the other type that a mint makes. A mint of another type fails before it generates one byte. "signer" is the private half of the current root key. "bind" is a hash reference of key stem to path, and a first root mint takes one entry for each key in force.

An OpenPGP mint needs "email", which becomes the one user id of the key, and it takes an optional "expires". "expires" is a date of the form "YYYY-MM-DD", and the key expires at the start of that date in UTC. A mint with no "expires" makes a key with no expiry. A mint of another type takes neither option.

The "key" block of an OpenPGP key takes the "email" and the "fingerprint" of the new key. The fingerprint comes from the key that the step generated, and never from an argument.

"secret" must name no file that stands, and it must not name "signer". The private half of a key is the one thing that a rotation cannot make again, and a caller holds each one in one place.

The method answers a hash reference with "name", "stem", "serial", "status", "digest" and "url", or undef with a reason in "error". "url" stands when the description names a published prefix.

$rotate->import_key(purpose => $word, file => $path, ...)
Publish a key that another tool made, under the next name of the purpose. "file" is the public key file, and "secret" is the private half of that key, which writes the binding of the new key. The caller holds that half already, so the step writes no key file of its own.

"type" names the key type, and the step reads every type that a key directory holds: "signify", "openpgp" and "x509". An "x509" "file" holds one PEM certificate, and a file that holds a private key or a second block fails the step. "secret" is then an unencrypted PEM private key: the verbs take no passphrase, so a caller that holds a PKCS#12 file converts it first.

The "key" block of an OpenPGP key and of a certificate takes the "fingerprint" of the key that the step published. An import takes no "email", because it generates no user id.

The step takes every other option of a mint of its purpose, and it answers the same facts.

$rotate->promote(purpose => $word, ...)
Make the "next" key of the purpose current, and retire the key that was current with an "until" date.

"retiring" is the private half of the key that retires, and it signs the chain binding. A promote of a subordinate purpose takes "signer", the private half of the current root, which signs the manifest. A root promote takes "secret", the private half of the key that becomes the root, which signs the manifest, and one "bind" entry for each key in force.

The retired key keeps its file and its published URL, so a release that it signed still verifies.

The method answers a hash reference with "name", "stem", "serial", "status" and "retired", or undef with a reason in "error".

$rotate->config
The description. A step loads it again after it writes, so a caller that holds this object reads the current state.
$rotate->error
The reason of the most recent failure, or undef.
$rotate->tool_missing
True when the failure was an absent command of a key type. A caller answers a missing tool with its own exit code, so a script tells it from a step that failed.

It writes no secret store and it opens no pull request. It reaches no network and it holds no token. A caller stores the private key and declares the public one.

App::FuguWeb::Keys, Fugu::KeyDir, Fugu::Signify, Fugu::OpenPGP, Fugu::X509, App::FuguWeb::Config, signify(1), gpg(1)

Dick Olsson <hi@senzilla.io>

2026-10-21 OpenBSD