Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab · advanced · ~60 min

Lab 18: Issue and revoke SSH certificates

C · SimulationB · Nested virtualisation

Objectives

  • Keep a serial register that lets a revocation list address a specific issued certificate
  • Issue certificates with validity windows measured in hours rather than years, and observe the window closing
  • Build a key revocation list from a serial number and merge a second entry by key identity
  • Prove a certificate is revoked offline with ssh-keygen -Q, before any authentication is attempted
  • Reproduce the wrong-principal, expired and revoked refusals and name each one from its server-side log line
  • State when a revocation list is the right control and when a short validity window has already solved the problem

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 ssh and ssh-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.22 and publish a port on 127.0.0.1.
  • About 60 MB of disk and a working directory under $HOME.
  • No out-of-band access requirement. Your host’s sshd, ~/.ssh and firewall are untouched. The server this lab reconfigures runs in a container reachable only on 127.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
Read-only / Safeserver side - what success looks like, so you can recognise its absence
$ 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.

Read-only / Safeserver side - refusal one, named exactly
$ 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+DcTgKtE

Illustrative 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
Read-only / Safelab host - a certificate whose validity window ended an hour ago
$ ssh-keygen -L -f alice-cert.pub
alice-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-rc

Illustrative 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
Read-only / Safeserver side - refusal two, alongside the bare-key attempt from the same session
$ docker logs rbpki-ssh18 2>&1 | grep -E 'expired|Failed publickey' | tail -3
Failed 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+DcTgKtE

Illustrative 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/"
Configuration changelab host - revoking one serial issued by one CA
$ 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:

  • -k says “generate a revocation list” rather than “sign a key”.
  • -s user_ca.pub names the CA whose serial numbers these are. Serial 1001 is meaningless on its own; it is serial 1001 as issued by this CA.
  • -z 1 is 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.

Read-only / Safelab host - the offline verdict, with no server involved
$ ssh-keygen -Qf revoked.krl alice-cert.pub
alice-cert.pub (alice@lab): REVOKED

Illustrative 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.

Read-only / Safeserver side - refusal three, which never mentions the certificate contents
$ docker logs rbpki-ssh18 2>&1 | grep revoked
Authentication key ED25519-CERT SHA256:/hOjaMxlXaTaWekzkFCIxR4PRU/c7eLkooB565Y7X6I revoked by file /tmp/revoked.krl

Illustrative 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.txt contains 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.txt is at least 1. 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.txt is at least 1. 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.txt is at least 1, and the path it names is /tmp/revoked.krl.
  • ssh-keygen -Qf "$LAB/ca/revoked.krl" some-cert.pub exits non-zero for the revoked certificate and 0 for the serial-1004 certificate. Both exiting 0 means the list was replaced rather than merged in Task 7.
  • The final ssh ... hostname in Task 9 exits 0. 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. -z exists 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: or sha256: stops every certificate ever issued to it, and it also belongs on the client side via RevokedHostKeys when the compromised key is a host key.
  • RevokedHostKeys fails 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 with Certificate invalid: name is not a listed principal, Certificate invalid: expired, and revoked 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 -Q gives 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 RevokedKeys file 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.

Deliverables

  • · serials.txt — the issuance register: serial, key identity, principal and validity window for every certificate signed
  • · revoked.krl — the binary revocation list the server enforced
  • · krl-inspection.txt — the revocation list contents and the ssh-keygen -Q verdict for each certificate
  • · failure-matrix.txt — the three refusals, each with its client message and the server line that explains it

Verification status

Last reviewed
2026-08-26
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.