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 totang1and decrypted per-signature —host-ca-autosign.timerrenews short-lived certs hands-off (a stolen/off-LANctrlfails closed). Its public key is a 3rd line inssh_host_ca.pub(additive trust). Only the host CA went online — the user CA (which grants access) stays YubiKey-gated here. Therotate-host-certs.shYubiKey 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 staticalmalinuxkey, 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-passplaintext 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.kdbxis 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 onatomic(FIDO2age-plugin-fido2-hmac,master.ageencrypted to both tokens → either arms it) decrypts a per-session copy of the master password and ships it toctrl. It uses the FIDO2 applet, so it does not consume or conflict with the PIV CA slots. (passequivalent: 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-kdbxpath (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 + anAnsible Vaultentry whose password is the vault passphrase. Two readers, one password:scripts/secrets.shis the human/break-glass reader (lists + reveals entries, and unlocks the vault from theAnsible Vaultentry); Ansible's own runs usescripts/vault-pass.sh(ansible.cfg→vault_password_file), which resolves the same passphrase via the freedesktop Secret Service (exposelab.kdbxover D-Bus + add the attributevault = lab-ansible-vault-adminto theAnsible Vaultentry, whichsecret-tool lookupexpects) or, headless, viaLAB_VAULT_PASS_FILE. (passremains 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.shneedskeepassxc-cli(KeePassXC); without it, it degrades to vault-only (setANSIBLE_VAULT_PASSWORD[_FILE]).-c(clipboard) only works in a graphical session.- Back up
lab.kdbx+~/keepass.txtindependently — both are gitignored, so they ride no git history (same asvault.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.shresolves it for Ansible), unlocklab.kdbxin KeePassXC, expose it over D-Bus, and add the attributevault = lab-ansible-vault-adminto theAnsible Vaultentry. CI stays separate: alab-ansible-vault-cientry = the ForgejoLAB_VAULT_PASS; the runner holds onlyci.
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.pub → HostCertificate (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 -t → systemctl reload ssh |
/usr/local/sbin/sshd -t → configctl 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'sauth_principals/rootalso listsadmin-hv, a DEDICATED principal that maps toroot@pveand 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-modeadminprincipal (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):Then sign a scoped task cert (YubiKey):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 setscripts/yubikey-tool.sh sign-agent-task admin-hv +4h provision. The agent then reaches pve asroot@10.20.10.5for 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 pressdin either menu). It checks the two things that fail independently, and reports both:
- tools —
ssh-keygenplus thelibykcs11PKCS#11 provider (fromyubico-piv-tool) are what actually sign.ykmanis 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>.pubpresent? 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
ctrlit 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
adminon EVERY token's 9a login cert.adminis 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 onlyadmin. Seen in practice — the tokens drift out of parity. The original instance of this was token A signed-n userwhile B was-n admin, so only B could driverotate-host-certs.sh. A verified check on 2026-07-19 found a different but equivalent split: A carried onlyadmin-mgmtand B onlyjump(a transit principal that lands nowhere), and B's cert had already expired. Neither token heldadmin; 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):
adminstays 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 inansible/group_vars/all/principals.yml(single source of truth —commontemplates each host'sauth_principalsfrom it; apply with--tags principals), andscripts/yubikey-tool.shreads 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:Principals = each host's names + IP (fromansible-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 sshcainventory.ini). Also add the@cert-authority *.${domain},*.${ipa_domain},10.20.* <host_ca.pub>line to your workstation~/.ssh/known_hostsso 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
+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:443—tls→origin passthrough,ssh→a local forward-onlyjumpaccount (no shell;PermitOpen= only10.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
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
almalinuxaccount + console. - PVE: a static
authorized_keysentry + 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.1succeed 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).