Skip to content

20 — SSH certificate authority (user + host CAs)

SSH access is certificate-based, not static keys: a short-lived, CA-signed cert (principal admin) gets you in everywhere, host certs verify host identity (no TOFU), and a KRL revokes compromised certs fleet-wide. This runbook is the operating manual.

Scope. This runbook owns the certificate authority: what the fleet trusts, what a cert may do, and how certs are issued and revoked. The device it runs on — the YubiKey's applets, its PINs, enrolling and retiring a token, and the drills that prove any of it works — is runbook 45. The test: if a fact changes when you swap a token, it lives in rb45; if it changes when you alter what the fleet trusts, it lives here.

Secret custody policy (two tiers — the canonical model)

Governing principle: match protection to the value of the secret. The classic mistake is uniform handling — applying crown-jewel ceremony to regenerable service passwords (touch-required friction that breaks automation) while under-protecting the keys that actually unlock everything. The lab's secrets split into two tiers, handled differently on purpose.

Tier 1 — crown jewels Tier 2 — operational secrets
What SSH CA private keys; admin personal SSH identity Ansible vault password + vault contents (Grafana pw, TSIG keys, ntfy creds)
Blast radius if leaked forge access to the whole fleet bad but bounded + regenerable (rotate the service secret)
At rest generated on-device, non-exportable (YubiKey PIV), touch=always, pin=once high-entropy secret in a password manager; vault file encrypted-at-rest (git-safe)
Backup none — no extractable copy ever exists; redundancy is a 2nd token's own keys passphrase copy in the safe; contents reproducible from code
Presence factor physical touch per signature none (do not touch-bind — it breaks CI)
Redundancy N independent tokens — each generates its own CAs; fleet trusts all password-manager sync/export

Tier 1 — the SSH CAs (per-token, independent, symmetric)

Every YubiKey is a complete, independent signer: it generates its own ECDSA P-384 CAs on-device (non-exportable, --touch-policy=always --pin-policy=once), so no key ever touches a computer and there is no backup to steal. The fleet trusts all tokens' CA pubkeys, so any token can sign and either keeps working if the other is lost. (P-384, not Ed25519: stock ssh-keygen handles it on any PIV firmware; ECDSA nonce risk is moot on the YubiKey's HW RNG.)

Each token holds, all on-device:

Slot Role Cert
9c this token's user CA self-signed X.509 (so PKCS#11/libykcs11 exposes it)
9d this token's host CA self-signed X.509
9a this token's admin SSH login key a user cert signed by this token's own 9c, principal admin

Enrolled tokens (fingerprints):

Token User CA (9c) Host CA (9d)
A SHA256:i/8gSVFCyFewrCbwLfDCP3sae22E64bdk67EI3X0Mq0 SHA256:u04HFUIzw7P2/xo+3RonFc6QEI+HP94iUhtMZmaGoyY
B SHA256:iSlddr5CPUOTa4icGaEV6LuIgK9B38WhGwWYOcODl0Y SHA256:Kt+kGcNY5UbZ/7CCFhnyWqVTnN6bGgd+Pt7Rpn57eKY

ssh_user_ca.pub lists both user-CA lines (→ TrustedUserCAKeys); ssh_host_ca.pub lists both host-CA lines (→ one @cert-authority line each). The 9a cert is the admin SSH identity (ssh -I <libykcs11> -o CertificateFile=~/.ssh/id_piv9a_<L>-cert.pub); a FIDO2 ed25519-sk is now optional (keep only for WebAuthn/passkeys).

Host certs are now auto-issued by an online host CA (runbook 26). The YubiKey host CA (9d) stays trusted as break-glass, but routine host-cert signing moved to an online host CA on ctrl, Clevis+Tang-sealed to tang1 and decrypted per-signature — host-ca-autosign.timer renews short-lived certs hands-off (a stolen/off-LAN ctrl fails closed). Its public key is a 3rd line in ssh_host_ca.pub (additive trust). Only the host CA went online — the user CA (which grants access) stays YubiKey-gated here. The rotate-host-certs.sh YubiKey flow below remains the host-CA break-glass.

Provisioning and retiring a token are runbook 45 (§6 and §7). What rb20 owns is the trust-file consequence: a new token contributes two pubkey lines — its 9c to ssh_user_ca.pub, its 9d to ssh_host_ca.pub — committed and deployed with --tags sshca, plus the appliances by hand. Retiring one deletes those two lines and re-signs any host certs it signed; the surviving token keeps working throughout.

CA private keys live only on the YubiKeys, generated on-chip — no cold backup exists by design. Re-establishing trust on total loss is a fresh on-device key + --tags sshca (cheap, since Ansible's transport is the static almalinux key, not a cert — a CA change can never lock the fleet out).

Tier 2 — the Ansible vault password

The vault gates regenerable service secrets, so it gets a right-sized, automation- compatible custody — not a touch-required PIV envelope.

  • Never a plaintext dotfile. The current ~/.config/lab-vault-pass plaintext is the real weak link — replace it. Keep the vault passphrase as a high-entropy entry in a password manager + a copy in the safe.
  • Recommended manager: KeePassXC (KDBX). It fits the lab's "boring, offline, no-SaaS, low-churn" ethos better than cloud managers — KDBX is an open, standardized, audited, single-file encrypted format you own, so it rides the existing Proxmox/ZFS + safe backup story and has no vendor/subscription dependency. (pass — gpg-backed files — is the lighter Unix alternative if you prefer it.)
  • Hardware presence gates the manager, not each secret. lab.kdbx is a password-only DB; the YubiKey enters at the unlock convenience layer, not as a factor baked into the DB. The shipped scheme is the presence-unlock (rb47): the operator's YubiKey on atomic (FIDO2 age-plugin-fido2-hmac, master.age encrypted to both tokens → either arms it) decrypts a per-session copy of the master password and ships it to ctrl. It uses the FIDO2 applet, so it does not consume or conflict with the PIV CA slots. (pass equivalent: put its GPG key on the YubiKey OpenPGP applet.)

    Retired (2026-07-18): the DB used to AND-combine its password with a YubiKey HMAC-SHA1 challenge-response factor (OTP applet, slot 2, the same symmetric secret on both tokens). That was dropped when rb47 landed — the DB was rekeyed to password-only. Do not re-add a CR factor; it breaks the agent's arm-kdbx path (rb45 §3.5).

  • The lab's KeePass DB is ansible/group_vars/all/lab.kdbx (next to the vault, gitignored), master password in ~/keepass.txt (or unlock interactively). It holds the break-glass admin logins + an Ansible Vault entry whose password is the vault passphrase. Two readers, one password: scripts/secrets.sh is the human/break-glass reader (lists + reveals entries, and unlocks the vault from the Ansible Vault entry); Ansible's own runs use scripts/vault-pass.sh (ansible.cfgvault_password_file), which resolves the same passphrase via the freedesktop Secret Service (expose lab.kdbx over D-Bus + add the attribute vault = lab-ansible-vault-admin to the Ansible Vault entry, which secret-tool lookup expects) or, headless, via LAB_VAULT_PASS_FILE. (pass remains a lighter alternative.)
  • Do NOT envelope-encrypt the vault password to PIV 9a. It applies crown-jewel ceremony to Tier-2 secrets, adds a moving part, and — fatally — a touch-required token blocks headless CD (runbook's Forgejo pipeline).

The CI fork: automation cannot touch a YubiKey, so vault-touching CD uses a separate, automation-only secret, scoped to the runner and rotated independently — never the master vault. This is built: ansible/ci/ is an isolated vault scope (its own ansible.cfg pins vault_identity_list = ci@…, and its own group_vars means the master group_vars/all/vault.yml is never inherited). The Forgejo runner holds only the ci password ($LAB_VAULT_PASS); interactive admin runs from the control node use the KeePassXC path above. See ansible/ci/README.md.

The lab KeePass store + secrets.sh

Create the DB once, then read both stores with scripts/secrets.sh (KeePass password from $KEEPASS_PASSWORD, $KEEPASS_PASSWORD_FILE, or a prompt):

# create the DB next to the vault (gitignored) + stash its master password
keepassxc-cli db-create -p ansible/group_vars/all/lab.kdbx        # set a strong password
umask 077; printf '%s\n' '<that password>' > ~/keepass.txt

# the bridge entry — its password = the ansible-vault passphrase — plus the admin logins
keepassxc-cli add -p ansible/group_vars/all/lab.kdbx "Ansible Vault"
keepassxc-cli add -p -u jfadmin ansible/group_vars/all/lab.kdbx "Jellyfin jfadmin"
#   …FreeIPA admin + Directory Manager, Grafana admin, tvheadend tvadmin, …

# read it
export KEEPASS_DB=ansible/group_vars/all/lab.kdbx KEEPASS_PASSWORD_FILE=~/keepass.txt
secrets.sh ls               # KeePass entries + every vault var name (pick from the list)
secrets.sh kp               # pick an admin login → reveal   (-c copies to clipboard)
secrets.sh av VAR           # reveal a vault var (vault unlocked via the 'Ansible Vault' entry)
secrets.sh run -- ansible-playbook playbooks/site.yml          # run with the vault unlocked
  • secrets.sh needs keepassxc-cli (KeePassXC); without it, it degrades to vault-only (set ANSIBLE_VAULT_PASSWORD[_FILE]). -c (clipboard) only works in a graphical session.
  • Back up lab.kdbx + ~/keepass.txt independently — both are gitignored, so they ride no git history (same as vault.yml); keep the passphrase in the safe too. Losing it = losing the vault.
  • The seeded admin passwords are point-in-time copies of the vault values — if you rotate one in the vault, update its KeePass entry too.
  • The KDBX is password-only; the YubiKey enters at the presence-unlock layer (rb47), not as a factor on the DB. The old shared OTP slot-2 challenge-response factor is retired (2026-07-18) — do not re-add it. See rb45 §3.5 (the retired secret) and rb36 (the unlock story).
  • For the Secret-Service automation path (so vault-pass.sh resolves it for Ansible), unlock lab.kdbx in KeePassXC, expose it over D-Bus, and add the attribute vault = lab-ansible-vault-admin to the Ansible Vault entry. CI stays separate: a lab-ansible-vault-ci entry = the Forgejo LAB_VAULT_PASS; the runner holds only ci.

Table-stakes underpinning both tiers

  • Full-disk encryption on the workstation (LUKS/FileVault) — every "it's only in a file" assumption depends on it.
  • Workstation login behind the YubiKey (FIDO2) where possible.

Trust distribution (CA = pure signer; Ansible distributes; git is the bus)

The CA only signs and commits public artifacts; nothing secret flows through Ansible. Pipeline:

gather host pubkeys  →  sign on workstation+token  →  git commit  →  ansible … --tags sshca

Fleet (Ansible, roles/common): deploys the public material to every Linux host: - ssh_user_ca.pub/etc/ssh/ssh_user_ca.pub + TrustedUserCAKeys. - ssh_host_ca.pub + a global ssh_known_hosts @cert-authority line (kills TOFU). - revoked_keys.krl/etc/ssh/revoked_keys.krl + RevokedKeys (tag krl). - per-host host_certs/<host>-cert.pubHostCertificate (conditional; no-op until signed). - AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u, with admin → the almalinux account.

Appliances (PVE + OPNsense — NOT Ansible-managed; runbook): the same trust, applied by hand (paths differ):

PVE (Debian) OPNsense (FreeBSD)
CA pubkey /etc/ssh/user_ca.pub (fleet CA) /usr/local/etc/ssh/ssh_user_ca.pub
principals /etc/ssh/auth_principals/root = admin,root,admin-hv /usr/local/etc/ssh/auth_principals/root
KRL /etc/ssh/revoked_keys.krl /usr/local/etc/ssh/revoked_keys.krl
drop-in /etc/ssh/sshd_config.d/20-user-ca.conf /usr/local/etc/ssh/sshd_config.d/20-user-ca.conf
validate / apply sshd -tsystemctl reload ssh /usr/local/sbin/sshd -tconfigctl openssh restart

Drop-in contents: TrustedUserCAKeys …, AuthorizedPrincipalsFile …/auth_principals/%u, RevokedKeys …. On both, cert principal admin → root (no static key needed; key/password kept only as break-glass). Verified: ssh -i <fleetkey> root@10.20.10.5 and root@10.20.10.1 log in as root via cert alone.

admin-hv — narrow hypervisor principal (root@pve only). pve's auth_principals/root also lists admin-hv, a DEDICATED principal that maps to root@pve and nothing else (it appears on no other host). This lets the AI agent get just-in-time hypervisor root (provisioning — lab-provision.sh, qm, pct) via a task cert, WITHOUT being handed the god-mode admin principal (which reaches every host + the whole jump path). It is the sharpest crown jewel (pve root = full-fleet blast radius), so it is task-only (never in the agent's standing cert) and short-TTL (+4h). Enable (one-time, in a root@pve shell):

grep -qxF admin-hv /etc/ssh/auth_principals/root || echo admin-hv >> /etc/ssh/auth_principals/root
sshd -t && systemctl reload ssh      # TrustedUserCAKeys/AuthorizedPrincipalsFile already set
Then sign a scoped task cert (YubiKey): scripts/yubikey-tool.sh sign-agent-task admin-hv +4h provision. The agent then reaches pve as root@10.20.10.5 for exactly that window. Revoke early via the KRL (scripts/push-krl.sh) like any cert.

Issuing certs (workstation + token)

First, confirm you can sign at all: scripts/yubikey-tool.sh --deps (or press d in either menu). It checks the two things that fail independently, and reports both:

  • toolsssh-keygen plus the libykcs11 PKCS#11 provider (from yubico-piv-tool) are what actually sign. ykman is only for provisioning and reading a CA pubkey out of a slot, so its absence does not stop signing — worth knowing, since it is the dependency people assume is essential. Anything missing is named with the install command (dnf/apt/brew auto-detected).
  • state — is a token plugged in, are 9a/9c/9d populated, and is this machine's ~/.ssh/lab_user_ca_<label>.pub present? No package fixes these: they mean "plug it in", "provision it (P)", or "you are on the wrong laptop".

It exits non-zero when you are not ready, so it works as a precondition in a script. Run it before you need it — the tool used to surface a missing dependency several keypresses into an operation, with no hint of what to install or what else was also absent, and the worst moment to learn you cannot sign is with the token already in hand.

On ctrl it correctly reports NOT ready: the user CA is deliberately not there — it stays YubiKey-gated on the admin laptop (the split-CA model above). That is the expected answer, not a fault.

User cert (short — +1w — so the CA key is exercised weekly):

ssh-keygen -s user_ca.pub -D /usr/lib/*/libykcs11.so -I operator-2026Qx -n admin \
  -V +1w -z <serial> operator.pub          # PIN + touch

Principal MUST be admin on EVERY token's 9a login cert. admin is the principal that maps to root fleet-wide (auth_principals/root); a login cert signed with any other principal (e.g. -n user) only maps to an unprivileged account where one exists and can't do admin SSH (rotation, deploys) at all on hosts that map only admin. Seen in practice — the tokens drift out of parity. The original instance of this was token A signed -n user while B was -n admin, so only B could drive rotate-host-certs.sh. A verified check on 2026-07-19 found a different but equivalent split: A carried only admin-mgmt and B only jump (a transit principal that lands nowhere), and B's cert had already expired. Neither token held admin; the last one that did expired 2026-06-25.

The lesson generalises past any single fix-up: login-cert principals drift, and the drift is silent — everything works until the day you reach for the other token. Check parity with rb45 drill D9, and prove it with D4 (operate for a day on one token with the other unplugged).

Scoped principals (least privilege): admin stays the broad break-glass principal (and the one that traverses the jump path, since every hop accepts it). For direct access you can scope a cert to a zone — every host also authorizes a role principal (admin-mgmt/admin-servers/admin-dmz), and crown jewels a dedicated one (admin-freeipa, instead of the zone principal). The taxonomy is codified in ansible/group_vars/all/principals.yml (single source of truth — common templates each host's auth_principals from it; apply with --tags principals), and scripts/yubikey-tool.sh reads it to offer a scope picker when signing, so you don't memorize names. Multi-hop jump certs carry several principals (e.g. admin-dmz,admin-servers). Host cert (+52w), from the gathered pubkeys:

ansible-playbook playbooks/gather-host-pubkeys.yml      # collect pubkeys -> repo
ssh-keygen -s host_ca.pub -D /usr/lib/*/libykcs11.so -h -I obs \
  -n obs.${domain},obs,10.20.20.50 -V +52w -z <serial> \
  roles/common/files/host_pubkeys/obs.pub
mv roles/common/files/host_pubkeys/obs-cert.pub roles/common/files/host_certs/obs-cert.pub
git commit … && ansible-playbook playbooks/site.yml --tags sshca
Principals = each host's names + IP (from inventory.ini). Also add the @cert-authority *.${domain},*.${ipa_domain},10.20.* <host_ca.pub> line to your workstation ~/.ssh/known_hosts so your laptop verifies host certs too.

Staggered fleet rotation — scripts/rotate-host-certs.sh

The per-host ssh-keygen above is the primitive; for the fleet use the wrapper, which gathers pubkeys → signs (PIN + touch per host) → deploys, with staggered validity so no two certs expire the same day. It's driven by the phasing tiers (group_vars/all/tiers.yml via scripts/tier.sh), so you rotate least-critical first:

scripts/rotate-host-certs.sh t0_workstations A   # workstations (gpu-host/desktop/admin-laptop)
scripts/rotate-host-certs.sh t1_canary A         # then up the tiers, verifying each
… scripts/rotate-host-certs.sh --all A           # or everything (still staggered)

Tiers auto-track inventory groups (e.g. t0_workstations = the [workstations] group), so adding a host to a group folds it into rotation with no edits; tier.sh --check errors if any host is untiered. Deploy routes by type: fleet via site.yml, edges via edge.yml, workstations via workstations.yml (all --tags sshca). Expiry is watched by the ssh_host_cert_days_left metric → Grafana/ntfy alert (runbook 18).

Running it where the token is — Fedora Atomic via toolbox

Signing must happen where the YubiKey is plugged in. On the Fedora Atomic laptop (MGMT zone — it reaches the git server + the fleet on :22; WORKSTATIONS-zone boxes do not and can't run this), use a toolbox rather than layering the immutable base. A toolbox shares the host network, $HOME, session D-Bus, and USB, so net reach, ~/.ssh, KeePassXC (vault via secret-tool), and the YubiKey all just work:

toolbox create cert-ops && toolbox enter cert-ops
sudo dnf install -y ansible-core yubico-piv-tool git   # yubico-piv-tool = libykcs11 (signer)
git clone http://10.20.30.30:3000/<org>/myos.git ~/myos   # or git.example.net
ykman list                                             # confirm the key is visible in the toolbox

# CRITICAL: ansible runs ssh WITHOUT a tty, so it can't prompt for the PIV PIN — every
# gather/deploy connection would fail "permission denied" instantly. Load the key into a
# real OpenSSH ssh-agent ONCE (PIN entered here; per-connection auth then needs only a
# touch, which is out-of-band). gnome-keyring's agent REFUSES pkcs11 — start openssh's:
eval "$(ssh-agent -s -P "$(realpath /usr/lib64/libykcs11.so.2)")"   # -P must allowlist the
ssh-add -s /usr/lib64/libykcs11.so.2                                # REAL path (ssh realpath-resolves the .so.2 symlink), else "agent refused operation"
ssh-add -l                                                          # should list the PIV key

cd ~/myos && scripts/rotate-host-certs.sh t0_workstations B        # use the token whose login cert principal is `admin` (see below)

Gotchas: - agent refused operation at gather/sign: either the agent doesn't hold the key (do the ssh-agent/ssh-add above) or the touch timed out — touch the key the instant it blinks, per host; don't let it fall back to a password. - sudo: a password is required on the localhost write: fixed in gather-host-pubkeys.yml (become: false) — the toolbox's sudo isn't passwordless. - Device-side symptoms — ykman can't see the key, pcscd not running, touch timing out, no PKCS#11 provider — are in rb45 §4.6. - If secret-tool can't reach KeePassXC over D-Bus: read -rs LAB_VAULT_PASS; export LAB_VAULT_PASS. (No rpm-ostree layering or reboot needed on the base image.) - Self-host (the admin laptop signing itself): works, but it's the box you're on — touch is easy to fumble. If its gather loses the race in a multi-host tier, run it solo: scripts/rotate-host-certs.sh admin-laptop B.

Revocation (KRL)

ssh-keygen -k -f roles/common/files/revoked_keys.krl -u -s roles/common/files/ssh_user_ca.pub <serial|key.pub>
git commit -m 'revoke …' roles/common/files/revoked_keys.krl
scripts/push-krl.sh        # fleet (ansible --tags krl) + PVE + OPNsense (scp), one shot
The KRL is read at auth time, so no restart is needed anywhere. Short cert validity (+1w user) keeps the revocation window small even between pushes.

No-VPN admin access (two-tier edge SSH jump)

For a machine that can't run WireGuard, SSH in over a two-tier relay — without exposing home's WAN or the management plane:

work machine ──TCP/443──▶ edge (caddy-l4 ssh mux)        # public relay, semi-trusted
             ──forward──▶ jump 10.20.30.5 (DMZ)           # internal pivot, trusted
             ───SSH:22──▶ any fleet host                  # cert-auth throughout
  • Edge (vps_edge): caddy-l4 multiplexes :443tls→origin passthrough, ssh→a local forward-only jump account (no shell; PermitOpen = only 10.20.30.5:22). The edge never reads the SSH and holds no CA key.
  • Internal jump (10.20.30.5): a minimal DMZ VM (common + alloy audit) — the one box an OPNsense rule lets reach the fleet on :22 (source 10.20.30.5 → 10.20.0.0/16:22, on the DMZ interface above the RFC1918 block). Every other DMZ host stays blocked, so an origin/edge compromise gains no fleet SSH.
  • Return path: the origin masquerades tunnel→DMZ/24 (origin_caddy), so the jump needs no route back to the tunnel subnet.

Your ~/.ssh/config (point HostName at an edge's public IP — round-robin across edges for failover; there is no jump.${domain} DNS name):

Host edgejump
  HostName ${edge.ns1_public}        # edge1 public IP (or ${edge.ns2_public} = edge2)
  Port 443
  User jump
Host fleet-*
  ProxyJump edgejump,jump@10.20.30.5
  User almalinux
Then ssh fleet-obs (HostName 10.20.20.50) = work → edge:443 → jump → obs. Verified end-to-end cert-auth; least-privilege confirmed (other DMZ hosts can't reach SERVERS:22).

Cert scope (least-privilege relay). Both jump tiers run a forward-only jump account (the vps_edge edge jump + the jump_relay role on 10.20.30.5) that authorizes the dedicated jump transit principal — no shell, PermitOpen only. So your cert carries jump (to relay) plus the target's own principal to land — e.g. sign jump,admin-servers for a SERVERS box. Relaying no longer needs the god-mode admin. Alternatively land as your real IPA identity: register the key once with ipa user-mod <you> --sshpubkey="$(cat ~/.ssh/id_piv9a_A.pub)", then a jump-only cert transits the relay and SSSD authenticates you@<target> on any IPA-enrolled host (not the DMZ jump itself, which is deliberately un-enrolled).

Provisioning a token (on-device — no migration, no import)

CAs are generated on-device, per token — nothing is exported, imported, or backed up. The on-device provisioning procedure (yubikey-tool.sh option P), programming the shared KDBX factor, and the acceptance drills are rb45 §6.

The step rb20 owns is publishing the trust: add the two printed lines to roles/common/files/ssh_user_ca.pub (the token's 9c) and ssh_host_ca.pub (its 9d) — one line per token — commit, then ansible-playbook playbooks/site.yml --tags sshca. Update the appliance TrustedUserCAKeys by hand (Proxmox + OPNsense are not Ansible-managed). Until this lands, the new token signs certs nothing accepts.

There is no key material to shred and no .bak to guard — the private keys never left the chips.

Break-glass (never depend solely on the CA)

  • Fleet: the local almalinux account + console.
  • PVE: a static authorized_keys entry + password + Proxmox console.
  • OPNsense: a static authorized_keys + the Proxmox console of its VM.
  • A CA outage (lost token, expired everything) is recoverable from the safe backup; keep at least one non-cert path until you've done a restore drill.

Validation

  • ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub → Signing CA = user-ca@ssh-ca, Principals = admin, short validity.
  • ssh root@10.20.10.5 / root@10.20.10.1 succeed via cert (no key/password).
  • Revoke a test cert → scripts/push-krl.sh → that cert is refused fleet-wide + appliances.
  • After host-cert signing: ssh <host> shows no TOFU prompt (cert-verified).
  • Device-level verification — every slot signs, each token works standalone, the KRL genuinely refuses — is the drill suite in runbook 45 §8.

Appendix — YubiKey operations

Moved. Device operations — the applets, the PIV slot map, PIN/PUK/management-key handling, lockout and reset behaviour, and the full ykman command reference — are now runbook 45, the YubiKey device operating manual.

The dividing line: rb20 owns the certificate authority (trust files, principals, cert validity, the KRL); rb45 owns the device (applets, secrets, enrolling and retiring a token, drills).