Objective
A certificate authority that only issues is a demo. This lab runs one the way an operator has to: with a register of what was signed, windows short enough that expiry is a routine event rather than an incident, and a working answer to “this credential must stop working now, before it expires”.
You will issue four certificates from one CA and then make three of them fail, each for a different reason, each producing the same message on the client and a completely different one on the server. By the end you will be able to look at a single sshd log line and say which of the three happened, which is the difference between a five-minute fix and an afternoon.
You will also be able to say something more useful: when a revocation list is genuinely the right control, and when reaching for one means the validity window was wrong in the first place.
Architecture
One CA, one register, one server, one revocation list. The revocation list is a file the server reads locally at authentication time. Nothing is fetched, nothing is queried, and there is no responder anywhere in the picture.
flowchart TD
CA["user CA private key"] --> R["serials.txt\nissuance register"]
CA --> C1["serial 1001\nprincipal deploy, 1h"]
CA --> C2["serial 1002\nprincipal backup, 1h"]
CA --> C3["serial 1003\nwindow already closed"]
R --> K["revoked.krl\nrevoke serial 1001"]
K --> S["sshd RevokedKeys\nchecked before anything else"]
C1 --> S
C2 --> S
C3 --> S
Read the arrows into sshd as four independent checks that all
have to pass: the CA signature must verify, the certificate must
not be listed in the revocation list, the current time must fall
inside the validity window, and the account being logged into
must appear in the principals list. Three of those four are about
to fail, one at a time.
Requirements
- OpenSSH 10.x client with
sshandssh-keygen. The revocation-list directives used here have been stable since 7.9, but the log lines quoted are from a 10.x server. - Docker 29.x, able to pull
alpine:3.22and publish a port on127.0.0.1. - About 60 MB of disk and a working directory under
$HOME. - No out-of-band access requirement. Your host’s
sshd,~/.sshand firewall are untouched. The server this lab reconfigures runs in a container reachable only on127.0.0.1:12223. - This lab stands alone. It rebuilds its own certificate authority in Task 2 rather than depending on artefacts from Lab 17, so the two can be run in either order or on different days.
Scenario
An engineer’s laptop has been reported stolen. They hold a valid SSH certificate issued this morning with an eight-hour window, and the certificate is on the laptop along with the private key, neither of which was passphrase-protected.
Nobody can tell you whether the certificate has been used since the theft, because nobody has looked at the right log line. You have two jobs: stop that specific certificate working across the fleet within minutes, and produce a written statement of which credentials the CA has issued and which of them are still live.
Tasks
Task 1 — Record the starting state
LAB="$HOME/rbpki-lab-18"
rm -rf "$LAB"
mkdir -p "$LAB/ca" "$LAB/client" "$LAB/server"
cd "$LAB"
{
echo "containers:"
docker ps -a --filter name=rbpki- --format '{{.Names}}'
echo "networks:"
docker network ls --filter name=rbpki- --format '{{.Name}}'
} > "$LAB/state.pre-lab"
cat "$LAB/state.pre-lab"
Both sections must be empty. If a rbpki- container or network
already exists, rename this lab’s resources before continuing;
Cleanup matches on that prefix.
Task 2 — Bootstrap the authority and the register
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -t ed25519 -f user_ca -C "RunBook Lab 18 User CA" -N ""
ssh-keygen -t ed25519 -f host_ca -C "RunBook Lab 18 Host CA" -N ""
ssh-keygen -t ed25519 -f "$LAB/client/alice" -C "alice@lab" -N ""
ssh-keygen -t ed25519 -f "$LAB/server/hostkey" -N ""
chmod 600 user_ca host_ca "$LAB/server/hostkey"
cp "$LAB/client/alice.pub" .
cp "$LAB/server/hostkey.pub" .
ssh-keygen -s host_ca -I "sshd.lab.example" -h -n sshd.lab.example \
-V -5m:+52w hostkey.pub
cp hostkey-cert.pub "$LAB/server/"
cp user_ca.pub "$LAB/server/"
printf 'serial\tkey_id\tprincipal\twindow\tissued_at\n' > "$LAB/serials.txt"
cat "$LAB/serials.txt"
The register is a tab-separated file because it needs to survive
being read by a person during an incident and by awk during an
audit. In production it is a row in the issuance service’s
database, but the fields do not change: a serial, the key
identity, the principals granted, the window, and when it was
signed. Without the serial column you cannot write a revocation
list at all, which is the single reason this file exists.
Task 3 — Start the server with an empty revocation list
An empty revocation list revokes nothing. Creating one now means
the RevokedKeys directive points at a file that exists from the
first second the daemon runs.
LAB="$HOME/rbpki-lab-18"
ssh-keygen -k -f "$LAB/ca/revoked.krl"
cp "$LAB/ca/revoked.krl" "$LAB/server/"
ls -l "$LAB/server/revoked.krl"
cat > "$LAB/server/sshd_config" <<'SSHDCONF'
Port 22
HostKey /tmp/hostkey
HostCertificate /tmp/hostkey-cert.pub
TrustedUserCAKeys /tmp/user_ca.pub
RevokedKeys /tmp/revoked.krl
PubkeyAuthentication yes
PasswordAuthentication no
AuthorizedKeysFile /dev/null
LogLevel VERBOSE
PidFile /tmp/sshd.pid
SSHDCONF
cat > "$LAB/server/start.sh" <<'START'
#!/bin/sh
set -e
apk add --no-cache openssh-server >/dev/null
adduser -D deploy
# BusyBox records a passwordless account as locked, and sshd will not
# complete public-key authentication for a locked account.
passwd -u deploy
for f in hostkey hostkey-cert.pub user_ca.pub revoked.krl sshd_config; do
cp "/etc/ssh/lab/$f" "/tmp/$f"
done
chmod 600 /tmp/hostkey
exec /usr/sbin/sshd -D -f /tmp/sshd_config -e
START
docker network create rbpki-net-18
docker run -d --name rbpki-ssh18 --network rbpki-net-18 \
--network-alias sshd.lab.example \
-p 127.0.0.1:12223:22 \
-v "$LAB/server:/etc/ssh/lab:ro" \
alpine:3.22 sh /etc/ssh/lab/start.sh
sleep 8
docker ps --filter name=rbpki-ssh18 --format '{{.Names}} {{.Status}}'
Then point the client at the host CA, so no first-contact prompt appears and the only variable in the rest of the lab is the user certificate.
LAB="$HOME/rbpki-lab-18"
{
printf '@cert-authority *.lab.example '
cat "$LAB/ca/host_ca.pub"
} > "$LAB/client/known_hosts"
Task 4 — Issue a working certificate and bank the evidence
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -s user_ca -I "alice@runbook-lab" -n deploy -V -5m:+1h -z 1001 alice.pub
printf '1001\talice@runbook-lab\tdeploy\t-5m:+1h\t%s\n' "$(date -u +%FT%TZ)" \
>> "$LAB/serials.txt"
cp alice-cert.pub "$LAB/client/"
ssh -i "$LAB/client/alice" \
-o "UserKnownHostsFile=$LAB/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 id
$ docker logs rbpki-ssh18 2>&1 | grep -E 'Accepted'Accepted certificate ID "alice@runbook-lab" (serial 1001) signed by ED25519 CA SHA256:6x39cg8... via /tmp/user_ca.pub
Accepted publickey for deploy from 172.25.0.1 port 51328 ssh2: ED25519-CERT SHA256:... ID alice@runbook-lab (serial 1001) CA ED25519 SHA256:...Illustrative output
The source addresses, ports and fingerprints in every capture in this lab are from the reference run and will differ from yours. What must match is the shape: a certificate decision naming the serial and the issuing CA, followed by an authentication decision naming the account. Keep this pair in mind, because each of the next three tasks removes one of them.
Task 5 — Refusal one: the principal does not match
Reissue the same key with principal backup, then keep logging
in as deploy.
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -s user_ca -I "alice@runbook-lab" -n backup -V -5m:+1h -z 1002 alice.pub
printf '1002\talice@runbook-lab\tbackup\t-5m:+1h\t%s\n' "$(date -u +%FT%TZ)" \
>> "$LAB/serials.txt"
cp alice-cert.pub "$LAB/client/"
ssh -i "$LAB/client/alice" \
-o "UserKnownHostsFile=$LAB/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 echo SHOULD-NOT-HAPPEN
The client prints
deploy@127.0.0.1: Permission denied (publickey,keyboard-interactive).
and exits 255. The server is specific.
$ docker logs rbpki-ssh18 2>&1 | grep -A1 'not a listed principal'Certificate invalid: name is not a listed principal
Failed publickey for deploy from 172.25.0.1 port 52038 ssh2: ED25519-CERT SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I ID alice@runbook-lab (serial 1002) CA ED25519 SHA256:6x39cg8OAp7PZgisHreCLRO7g0vp6s0VZIw+DcTgKtEIllustrative output
Two facts are worth extracting. First, the certificate itself is
sound: the CA signature verified, which is why the log can quote
the key identity and the serial. Second, with no
AuthorizedPrincipalsFile configured, the account name being
logged into must itself appear in the principals list, so a
certificate for backup opens nothing for deploy.
Task 6 — Refusal two: the window has closed
-V accepts a window entirely in the past, which is how you
observe an expired certificate without waiting.
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -s user_ca -I "alice@runbook-lab" -n deploy -V -2h:-1h -z 1003 alice.pub
printf '1003\talice@runbook-lab\tdeploy\t-2h:-1h\t%s\n' "$(date -u +%FT%TZ)" \
>> "$LAB/serials.txt"
cp alice-cert.pub "$LAB/client/"
ssh-keygen -L -f alice-cert.pub
$ ssh-keygen -L -f alice-cert.pubalice-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I
Signing CA: ED25519 SHA256:6x39cg8OAp7PZgisHreCLRO7g0vp6s0VZIw+DcTgKtE (using ssh-ed25519)
Key ID: "alice@runbook-lab"
Serial: 1003
Valid: from 2026-08-26T19:21:30 to 2026-08-26T20:21:30
Principals:
deploy
Critical Options: (none)
Extensions:
permit-X11-forwarding
permit-agent-forwarding
permit-port-forwarding
permit-pty
permit-user-rcIllustrative output
LAB="$HOME/rbpki-lab-18"
ssh -i "$LAB/client/alice" \
-o "UserKnownHostsFile=$LAB/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 echo SHOULD-NOT-HAPPEN
$ docker logs rbpki-ssh18 2>&1 | grep -E 'expired|Failed publickey' | tail -3Failed publickey for deploy from 172.25.0.1 port 52046 ssh2: ED25519 SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I
Certificate invalid: expired
Failed publickey for deploy from 172.25.0.1 port 52046 ssh2: ED25519-CERT SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I ID alice@runbook-lab (serial 1003) CA ED25519 SHA256:6x39cg8OAp7PZgisHreCLRO7g0vp6s0VZIw+DcTgKtEIllustrative output
Note the first line: the same key was also offered without
the certificate, and refused separately because
AuthorizedKeysFile /dev/null means no bare key is ever
acceptable. ED25519 and ED25519-CERT in those two lines are
how you tell the two attempts apart at a glance.
Expiry is the cheapest control in this lab. It required no file on the server, no distribution step and no operator action. The certificate simply stopped working.
Task 7 — Build the revocation list
Now handle the stolen-laptop case, where a certificate must stop working before its window closes. Reissue serial 1001 so there is a live credential to revoke, then revoke it.
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -s user_ca -I "alice@runbook-lab" -n deploy -V -5m:+1h -z 1001 alice.pub
cp alice-cert.pub "$LAB/client/"
$ printf 'serial: 1001\n' | ssh-keygen -k -f revoked.krl -s user_ca.pub -z 1 -Revoking from (standard input)Illustrative output
Three flags carry very different meanings from their certificate equivalents, and this is where people trip:
-ksays “generate a revocation list” rather than “sign a key”.-s user_ca.pubnames the CA whose serial numbers these are. Serial 1001 is meaningless on its own; it is serial 1001 as issued by this CA.-z 1is the revocation list version number, not a certificate serial. Increment it each time you publish a new list so that a host can tell an old copy from a current one.
The revocation directives read from standard input are their own
small language. serial: N and serial: N-M revoke a serial or
a range; id: key_id revokes by key identity, which is the only
option for a certificate signed without -z; key:, sha1:,
sha256: and hash: revoke a public key outright, taking every
certificate ever issued to it.
Add a second entry by key identity to see the merge behaviour, and bump the version.
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
printf 'id: alice@no-longer-employed\n' \
| ssh-keygen -k -u -f revoked.krl -s user_ca.pub -z 2 -
ssh-keygen -Q -l -f revoked.krl > "$LAB/krl-inspection.txt"
cat "$LAB/krl-inspection.txt"
-u merges into the existing list instead of replacing it.
Without it you would silently drop serial 1001 while believing
you had added to the list.
Task 8 — Prove revocation offline, then online
Check the certificate against the list before touching the server. This is what you would run during an incident to answer “is this credential still live”, and it needs no network access at all.
$ ssh-keygen -Qf revoked.krl alice-cert.pubalice-cert.pub (alice@lab): REVOKEDIllustrative output
The exit status is the part that automates. The manual states
that if any key listed on the command line has been revoked, or
an error is encountered, ssh-keygen exits non-zero, and a zero
exit status will only be returned if no key was revoked. That
inversion is deliberate: a script that checks a batch of
certificates gets one clean boolean, and an unreadable list
counts as a failure rather than as an all-clear.
Now publish the list to the server. Replace the file atomically, because the manual requires that a revocation list should only be atomically replaced and never modified in place while the server is running.
LAB="$HOME/rbpki-lab-18"
docker cp "$LAB/ca/revoked.krl" rbpki-ssh18:/tmp/revoked.krl.new
docker exec rbpki-ssh18 mv /tmp/revoked.krl.new /tmp/revoked.krl
docker exec rbpki-ssh18 ls -l /tmp/revoked.krl
ssh -i "$LAB/client/alice" \
-o "UserKnownHostsFile=$LAB/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 echo SHOULD-NOT-HAPPEN
docker cp writes to a temporary name and mv performs the
rename, which is atomic within a filesystem. A plain overwrite
would leave a window in which the daemon could read a truncated
list, and an unreadable list has consequences the next task
covers.
$ docker logs rbpki-ssh18 2>&1 | grep revokedAuthentication key ED25519-CERT SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I revoked by file /tmp/revoked.krlIllustrative output
That line names the file, which matters when a host has an old
copy. Compare it against the three refusals side by side:
Certificate invalid: name is not a listed principal is an
authorisation decision, Certificate invalid: expired is a clock
decision, and revoked by file is a local policy decision that
names the policy file responsible.
Task 9 — The failure the revocation list itself can cause
The manual carries a warning worth reproducing deliberately: if
the RevokedKeys file is not readable, public-key authentication
is refused for all users. Remove the file and see what your
build does.
LAB="$HOME/rbpki-lab-18"
cd "$LAB/ca"
ssh-keygen -s user_ca -I "alice@still-employed" -n deploy -V -5m:+1h -z 1004 alice.pub
printf '1004\talice@still-employed\tdeploy\t-5m:+1h\t%s\n' "$(date -u +%FT%TZ)" \
>> "$LAB/serials.txt"
cp alice-cert.pub "$LAB/client/"
docker exec rbpki-ssh18 mv /tmp/revoked.krl /tmp/revoked.krl.away
ssh -i "$LAB/client/alice" \
-o "UserKnownHostsFile=$LAB/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 id
docker logs rbpki-ssh18 2>&1 | tail -10
Record what you observe, then restore the file and confirm the new certificate works.
docker exec rbpki-ssh18 mv /tmp/revoked.krl.away /tmp/revoked.krl
ssh -i "$HOME/rbpki-lab-18/client/alice" \
-o "UserKnownHostsFile=$HOME/rbpki-lab-18/client/known_hosts" \
-o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes \
-o HostKeyAlias=sshd.lab.example \
-p 12223 deploy@127.0.0.1 hostname
Repeated failures earn a penalty from the server, and you have now generated several. OpenSSH applies source-address penalties by default and has done since 9.8, so a retry loop against a bad credential degrades into what looks like network slowness:
srclimit_penalise: ipv4: new 172.25.0.1/32 deferred penalty of 5 seconds for penalty: failed authentication
Task 10 — Capture the deliverables
LAB="$HOME/rbpki-lab-18"
cd "$LAB"
cp "$LAB/ca/revoked.krl" "$LAB/revoked.krl"
{
echo "=== revocation list contents ==="
ssh-keygen -Q -l -f "$LAB/ca/revoked.krl"
echo "=== verdict for the certificate currently in client/ ==="
ssh-keygen -Qf "$LAB/ca/revoked.krl" "$LAB/client/alice-cert.pub"
} > "$LAB/krl-inspection.txt" 2>&1
docker logs rbpki-ssh18 2>&1 \
| grep -Ei 'Certificate invalid|revoked by file|Accepted|Failed|srclimit' \
> "$LAB/failure-matrix.txt"
ls -l serials.txt revoked.krl krl-inspection.txt failure-matrix.txt
wc -l serials.txt failure-matrix.txt
Validation
serials.txtcontains a header plus at least four issuance rows, and every serial in it is unique. A duplicate serial means one revocation entry would cover two credentials.grep -c 'Certificate invalid: name is not a listed principal' failure-matrix.txtis at least1. A zero means Task 5 did not reach the server, usually because the reissued certificate was not copied into$LAB/client.grep -c 'Certificate invalid: expired' failure-matrix.txtis at least1. A zero here with a successful login means the system clock inside the container disagrees with the host by more than an hour.grep -c 'revoked by file' failure-matrix.txtis at least1, and the path it names is/tmp/revoked.krl.ssh-keygen -Qf "$LAB/ca/revoked.krl" some-cert.pubexits non-zero for the revoked certificate and0for the serial-1004 certificate. Both exiting0means the list was replaced rather than merged in Task 7.- The final
ssh ... hostnamein Task 9 exits0. A refusal here means the revocation list was not restored. - The four deliverables exist and are non-empty.
Expected Outcome
$HOME/rbpki-lab-18/
├── state.pre-lab
├── serials.txt
├── revoked.krl
├── krl-inspection.txt
├── failure-matrix.txt
├── ca/
│ ├── user_ca, user_ca.pub
│ ├── host_ca, host_ca.pub
│ ├── alice.pub, alice-cert.pub
│ ├── hostkey.pub, hostkey-cert.pub
│ └── revoked.krl
├── client/
│ ├── alice, alice.pub, alice-cert.pub
│ └── known_hosts
└── server/
├── hostkey, hostkey.pub, hostkey-cert.pub
├── user_ca.pub, revoked.krl
├── sshd_config
└── start.sh
You can now answer the stolen-laptop question with a procedure
rather than an opinion. The register tells you which serial was
issued to that engineer and when its window closes. If the window
closes within minutes, do nothing and say so. If it does not, add
the serial to the revocation list, bump the list version,
distribute it atomically, and prove the result offline with
ssh-keygen -Q before anyone tries to log in.
Troubleshooting
Every certificate is refused, including ones you never
revoked. The revocation list is missing, truncated or was
replaced non-atomically. Confirm with
docker exec rbpki-ssh18 ls -l /tmp/revoked.krl and re-copy it
using the temporary-name-then-mv sequence from Task 8.
ssh-keygen -k refuses the input. The revocation directives
are read from standard input only when the final argument is a
bare -. Without it, ssh-keygen expects file arguments and
produces an empty list. Check the trailing hyphen first.
serial: 1001 revokes nothing. A serial is only meaningful
relative to an issuing CA. Confirm -s user_ca.pub names the CA
that actually signed the certificate, and that you did not point
it at host_ca.pub.
The expired-certificate task succeeds instead of failing. The
container’s clock has drifted from the host’s. Compare
date -u on the host with docker exec rbpki-ssh18 date -u. A
container that has been suspended and resumed is the usual cause,
and recreating it is faster than fixing it.
Login is refused with no Certificate invalid: line at all.
The deploy account is locked. Run
docker exec rbpki-ssh18 passwd -u deploy. The start script does
this, so seeing it means the script exited before that line.
Cleanup
LAB="$HOME/rbpki-lab-18"
# 1. Stop and forget the container and its network.
docker rm -f rbpki-ssh18
docker network rm rbpki-net-18
# 2. Compare against the Task 1 capture before deleting the evidence.
cat "$LAB/state.pre-lab"
docker ps -a --filter name=rbpki- --format '{{.Names}}'
docker network ls --filter name=rbpki- --format '{{.Name}}'
# 3. Remove the lab directory, CA keys and revocation list included.
rm -rf "$LAB"
Confirm restoration by running
docker ps -a --filter name=rbpki- and
docker network ls --filter name=rbpki-: both must print only
their header row, matching state.pre-lab. Then run
test -d "$HOME/rbpki-lab-18" && echo "still present" || echo "removed".
Nothing was written to your own ~/.ssh, because every client
invocation named a lab-local known_hosts.
Production notes
- Distribution is the hard half. Signing a revocation list takes a second; getting the current version onto every host, proving it arrived, and alerting when a host falls behind is the actual project. A host with a stale list is a host on which the revoked certificate still works, and nothing about the login will look unusual.
- Version the list and monitor the version.
-zexists so a host can tell a current list from an old one. Export that number alongside the file’s age as a metric, and alert on divergence across the fleet rather than on a single host. - Prefer expiry to revocation. Every argument above disappears if the credential’s window is measured in hours. Revocation is the control for the gap between “we learned about the compromise” and “the certificate would have expired anyway”, and the way to shrink that gap is to shorten the window rather than to improve the distribution.
- Revoke the key, not just the certificate, after a key
compromise. A stolen private key can be recertified by anyone
who can reach your issuance service. Revoking the serial stops
one certificate; revoking the public key with
key:orsha256:stops every certificate ever issued to it, and it also belongs on the client side viaRevokedHostKeyswhen the compromised key is a host key. RevokedHostKeysfails closed too. The client-side directive carries the same warning as the server-side one: if the file does not exist or is not readable, host authentication is refused for all hosts.
What You Learned
- Three refusals, one client message. Wrong principal,
expired and revoked all produce
Permission denied (publickey,keyboard-interactive).. The server distinguishes them withCertificate invalid: name is not a listed principal,Certificate invalid: expired, andrevoked by file. - A serial you did not record is a certificate you cannot revoke by serial. The default serial is zero, zero is excluded from revocation lists, and a register is what makes the serial usable later.
- Revocation can be proved offline.
ssh-keygen -Qgives a verdict against the list with no server involved, and returns a zero exit status only when nothing on the command line was revoked. - The revocation list is itself a failure domain. An
unreadable
RevokedKeysfile refuses public-key authentication for every user, so the file must be deployed before the directive and replaced atomically afterwards. - Short windows are the primary control. Expiry needed no file, no distribution and no operator action. Revocation exists for the interval a short window has not already closed.