# Can a revoked SSH host key still get an agent in?

A revoked SSH host key should stop an agent before user authentication, regardless of the name or address the agent uses. If the test only tries the friendly hostname once, it proves far less than it appears to prove. SSH configuration can map several names to one server, a non-default port changes the known-hosts lookup token, and a multiplexed client can open another channel without performing a new host-key check.

Treat revocation as a property of the cryptographic identity, then test every route that can present or reuse that identity. That means a key revocation list, a clean client state, explicit connection variants, and a separate procedure for connections that already exist. A changed-key warning is useful, but it is not the same control.

## Revocation must follow the key, not the hostname

A host key authenticates the server during SSH key exchange. RFC 4253 describes the client receiving the server public host key, checking that it belongs to the intended server, and verifying the server's signature over the exchange. Revocation belongs at that check: when the server presents a forbidden public key, the client must reject it before it considers a user key, password, or remote command.

OpenSSH offers two mechanisms that look similar but have different reach. A line beginning with `@revoked` in a known-hosts file combines a host pattern with a revoked key. It works when the connection's lookup name matches that line. An alias or alternate address can miss the pattern, even when the server presents the same key. The OpenSSH `sshd` manual says a matching revoked entry must never be accepted, but the word matching matters.

The client-side `RevokedHostKeys` option is the stronger fit for incident revocation. It points to a text file containing public keys or to an OpenSSH Key Revocation List (KRL). OpenSSH checks the presented identity against that file independently of whether the user typed `build-test`, `build-test.example`, or an IP literal. I use `@revoked` entries for targeted known-host bookkeeping; I use `RevokedHostKeys` when a cryptographic identity must be dead everywhere.

Do not confuse either mechanism with `StrictHostKeyChecking=yes`. Strict checking refuses unknown hosts and changed keys, which prevents silent trust on first use. It does not declare an otherwise trusted key revoked. A test that deletes the old known-host entry and celebrates an unknown-host failure has tested an empty trust database, not revocation.

## Build a test target you can break on purpose

Use a disposable SSH server with a dedicated port, host key, user account, and client directory. Do not rehearse revocation against a shared staging host. You need to replace keys, stop processes, and close control sockets without wondering who else depends on them.

The following layout keeps the evidence in one directory. The exact `sshd` path and privilege model vary by operating system, so run the daemon in the same container, VM, or test fixture your team already uses for SSH integration tests.

```bash
set -eu
work="$PWD/ssh-revocation-fixture"
mkdir -p "$work/client" "$work/server"
chmod 700 "$work/client"

ssh-keygen -q -t ed25519 -N '' \
  -C revoked-host-test -f "$work/server/ssh_host_ed25519_key"
ssh-keygen -q -t ed25519 -N '' \
  -C agent-test-user -f "$work/client/id_ed25519"

ssh-keygen -lf "$work/server/ssh_host_ed25519_key.pub"
```

The final command prints a line shaped like this:

```text
256 SHA256:<base64-fingerprint> revoked-host-test (ED25519)
```

Record that fingerprint in the test log. Never copy a fingerprint from a ticket into the fixture and assume the file contains the same key. Compute it from the public-key file that the test server actually loads.

Configure the test daemon to use only that host key. Give it an `AuthorizedKeysFile` populated with the client public key, disable passwords, and bind it to a loopback or isolated address. A minimal fixture should also set an explicit `PidFile` and verbose logging. The goal is determinism: one listener, one host identity, and one authentication path.

Before adding revocation, make one strict connection succeed and run a harmless sentinel command such as `printf BASELINE_OK`. Capture the host key with an authenticated provisioning step, not an unaudited `ssh-keyscan` performed across the same network path you are trying to protect. `ssh-keyscan` retrieves keys without proving who supplied them.

## Put the revoked identity in a KRL

A KRL makes the test about the key rather than the spelling of a destination. Generate it directly from the public key loaded by the fixture, then query it before any network attempt:

```bash
work="$PWD/ssh-revocation-fixture"
ssh-keygen -k -f "$work/client/revoked-hosts.krl" \
  "$work/server/ssh_host_ed25519_key.pub"

if ssh-keygen -Q -f "$work/client/revoked-hosts.krl" \
  "$work/server/ssh_host_ed25519_key.pub"; then
  printf '%s\n' 'FAIL: fixture host key is not revoked'
  exit 1
else
  printf '%s\n' 'OK: fixture host key is revoked'
fi
```

The inverted exit status catches people. The `ssh-keygen` manual specifies that `-Q` returns nonzero when any queried key is revoked or an error occurs; zero means no queried key was revoked. Do not discard stderr, and distinguish an unreadable KRL from a valid revocation match in the surrounding test log.

Now attach the file to the client configuration at the broadest scope used by the agent:

```sshconfig
Host *
    BatchMode yes
    StrictHostKeyChecking yes
    UserKnownHostsFile ./ssh-revocation-fixture/client/known_hosts
    RevokedHostKeys ./ssh-revocation-fixture/client/revoked-hosts.krl
    ConnectTimeout 5
    ConnectionAttempts 1
```

OpenSSH deliberately fails closed if the `RevokedHostKeys` file does not exist or cannot be read: it refuses host authentication for every destination covered by that configuration. That is safer than ignoring the file, but it can make a broken deployment look like a successful revocation test. The preflight query above proves that the file is readable and contains the intended key.

A plain text file with one public key per line also works. KRLs earn their complexity when you have many keys or host certificates because they can revoke plain keys, certificate serials, certificate key IDs, or keys signed by a CA. Use the simplest format your distribution and inspection process can handle, then test the format you deploy.

## A hostname-scoped marker needs an adversarial test

Keep one deliberately weak case in the fixture to show why the KRL is necessary. Build a second client configuration that has no `RevokedHostKeys` option and relies only on this known-hosts entry:

```text
@revoked revoked-lab ssh-ed25519 AAAA...fixture-public-key...
```

Connect as `revoked-lab` and confirm that OpenSSH rejects the matching entry. Then connect to the same listener as `127.0.0.1`, using a separately trusted known-host entry for `[127.0.0.1]:2222`. If that address form succeeds, the fixture has reproduced the coverage gap: the key did not change, but the lookup name no longer matched the revoked marker. Keep this demonstration isolated from production configuration because its purpose is to prove a control is insufficient.

Do not paste the abbreviated `AAAA...` value from the example. Construct the marker from the actual public-key file so the algorithm and base64 key data are exact. A safe fixture command can read the algorithm and key fields from the public file and prepend the marker plus the intended host pattern. Verify the resulting record with `ssh-keygen -F revoked-lab -f known_hosts`; repeat the lookup for the literal address to show that the record is absent there.

This failure explains why adding every known alias to an `@revoked` line is fragile. The inventory changes whenever someone adds a short name, DNS record, local hosts-file entry, non-default port, tunnel alias, or `HostKeyAlias`. Hashed known-host names make manual review harder, though `ssh-keygen -F` can still search them. A wildcard marker broadens matching but may revoke the public key only when a destination falls inside that pattern; it also makes the intended scope harder to review.

The KRL case should use the same server, user key, network route, and trusted known-host records. Change only the revocation mechanism. With `RevokedHostKeys` active, both `revoked-lab` and `127.0.0.1` must fail after presenting the fixture key. This paired experiment turns an abstract warning about aliases into a failure reviewers can see and rerun.

Also test precedence. OpenSSH reads configuration values according to its first-obtained-value rules, so an early host-specific setting can defeat a later intended default. Run `ssh -G` against each destination and compare the resolved `revokedhostkeys` path byte for byte. A configuration review that merely finds the word `RevokedHostKeys` somewhere in a file does not prove the agent uses it.

System and user configuration create another split. An engineer may have `/etc/ssh/ssh_config` pointing at an organization KRL while an agent launches with `ssh -F private-config`, which tells OpenSSH to use an alternate configuration file. Conversely, a test may pass under a clean CI account but fail to model a production runner that injects options on the command line. Capture the full invocation at the agent boundary and make the revocation path explicit there.

Permissions belong in the acceptance test. Readability failures refuse all host authentication under `RevokedHostKeys`, according to the OpenSSH manual. Check the file as the agent's operating-system user before connection, and query the intended key. This separates three outcomes that all look like a failed SSH command: a valid revocation match, a missing or unreadable revocation file, and a malformed KRL.

Distribution has a timing edge as well. If several runner machines can launch the agent, publishing the KRL on one machine is not complete revocation. Record a content digest for the KRL and require every runner to report that digest before allowing new SSH work. Use an atomic replacement process so a reader never observes a partly written file. The negative suite should run after distribution from each distinct client image or configuration class, not only on the machine that produced the KRL.

Rollback must have a testable meaning. Removing a fingerprint from a KRL because a ticket was closed can restore trust while the compromised private key still exists. Prefer replacing the server identity, distributing the new trust record, and retaining the old identity in revocation. If policy permits removing a revocation later, require evidence that the old private key cannot return and keep a regression case that presents it.

The acceptance record for one revoked identity should therefore contain the public-key fingerprint, the public-key algorithm, the KRL digest, the set of tested destination forms, the effective configuration captured for each form, and the fresh-handshake result. Add the multiplexed-session result separately because it answers a different question. Reviewers can then tell whether a failure reflects identity matching, route coverage, configuration, distribution, or transport cleanup.

## A fresh connection must fail before the sentinel runs

Force the first negative case to establish a new transport. Disable connection sharing on the command line even if the user's SSH configuration enables it, keep the client noninteractive, and place a marker in the remote command that would be unmistakable if execution occurred.

```bash
work="$PWD/ssh-revocation-fixture"
config="$work/client/config"
log="$work/client/direct.stderr"

set +e
ssh -F "$config" \
  -o ControlMaster=no -o ControlPath=none \
  -i "$work/client/id_ed25519" \
  -p 2222 testuser@127.0.0.1 \
  'printf REVOKED_KEY_EXECUTED' \
  >"$work/client/direct.stdout" 2>"$log"
rc=$?
set -e

if [ "$rc" -eq 0 ]; then
  printf '%s\n' 'FAIL: SSH accepted a revoked host identity'
  exit 1
fi
if grep -q REVOKED_KEY_EXECUTED "$work/client/direct.stdout"; then
  printf '%s\n' 'FAIL: remote sentinel ran'
  exit 1
fi
printf 'PASS rc=%s\n' "$rc"
```

Assert the outcome, not one exact English error string. OpenSSH versions and operating systems can phrase diagnostics differently. Preserve verbose client output from a second run with `-vv` when a case fails, and look for the presented fingerprint plus a revocation diagnostic. A TCP timeout, refused port, unknown host, missing user key, or denied account also returns nonzero, but none proves that host-key revocation stopped the connection.

The server log supplies the other half of the assertion. The client should disconnect during key exchange, before the server records successful user authentication and before it starts a session. If the fixture cannot separate those phases in its evidence, it is too opaque for this test.

Run one control case with an empty, readable KRL or a different nonrevoked server key. That connection should reach `BASELINE_OK`. A negative test without a positive control often passes because DNS, routing, file permissions, or the fixture account was broken all along.

## Every alias and address form needs its own case

A KRL should reject the key across names, but configuration resolution can still route one spelling around the revocation option. Test the effective client configuration and the network result for every destination form an agent can produce.

Start with a small matrix tied to the fixture:

1. The configured alias, such as `revoked-lab`, whose `HostName` points to the test address.
2. The fully qualified hostname and any short hostname accepted by local resolver rules.
3. The IPv4 literal and, when the listener has it, the IPv6 literal.
4. The non-default-port known-host token, conventionally written as `[host]:port` in known-hosts tools.
5. An alias that sets `HostKeyAlias`, because that option replaces the real hostname for host-key lookup and certificate validation.

Keep the variants in a data file or test function rather than duplicating a shell block. For each user-facing name, inspect `ssh -G destination` and save the resolved `hostname`, `port`, `hostkeyalias`, `userknownhostsfile`, `revokedhostkeys`, `proxycommand`, `proxyjump`, `controlmaster`, and `controlpath` values. `ssh -G` expands configuration without connecting, so it catches a `Host` stanza that silently overrides the KRL path.

`Hostname` and `HostKeyAlias` do different jobs. `Hostname` selects the network destination. `HostKeyAlias` selects the name OpenSSH uses when reading or writing host keys and validating host certificates. Neither should weaken a global `RevokedHostKeys` check, but both can change which trusted known-host entry OpenSSH considers. That is why a hostname-scoped `@revoked` line alone is a poor incident control.

Canonicalization deserves a case when it is enabled. `CanonicalizeHostname yes` can append configured domains and causes OpenSSH to process configuration again using the rewritten target. A later matching stanza may select a different known-hosts file or omit the revocation file. Test the short input and the resulting canonical name, then confirm both effective configurations name the same KRL.

Do not treat `CheckHostIP=yes` as alias coverage. The OpenSSH manual says it additionally checks the destination IP in known-hosts, and it is unavailable with a proxy command. It can detect a changed association, but it does not replace a key-centric revocation file. Proxy and jump paths still need an end-to-end test for the final target, with separate trust configuration for each jump host.

## Cached connections are an incident-response boundary

A live multiplexed SSH master has already completed server authentication. Opening another session through its control socket does not perform a new TCP connection or host-key exchange, so a newly installed KRL cannot reject the host key retroactively. Calling that a revocation bypass muddies the model. It is reuse of an authenticated transport that still exists.

Prove this behavior in the fixture instead of assuming a restart will clean it up. First establish a multiplexed master while the host key is allowed. Keep it alive with `ControlPersist`, add the key to the KRL, and show that `ssh -O check` still finds the master. A command sent through that socket may still run. Record this as the expected pre-remediation control, not as a passing revocation result.

Then perform the response action: prevent the agent from opening new work, terminate relevant control masters, and revoke or stop the agent session that owns them. For a dedicated fixture socket, the local command is shaped like this:

```bash
socket="$PWD/ssh-revocation-fixture/client/cm-testuser-127.0.0.1-2222"
ssh -S "$socket" -O exit testuser@127.0.0.1
```

After the socket disappears, repeat the connection with `ControlMaster=no` and `ControlPath=none`; it must fail on the revoked key. Also test the agent's normal multiplexing configuration after cleanup. Opportunistic modes such as `ControlMaster auto` fall back to a fresh connection when no master listens, and that fallback must hit the KRL.

A stale socket file is not an authenticated connection. OpenSSH will try the socket, discover that no master listens, and may fall back to a normal connection. Leave this case in the suite because stale files appear after crashes. The safe result is still a revocation failure during the fresh handshake.

Revoking a host identity therefore requires two controls during an active incident: reject future key exchanges and end transports that authenticated before revocation. A test that covers only one control gives agents either a new route in or an old route that never closed.

## One server can present more than one identity

Servers commonly load several host keys or a host certificate plus its underlying key. Revoking one fingerprint does not necessarily mean the endpoint becomes unreachable. During negotiation, the client and server select a mutually supported host-key algorithm; a different, trusted, nonrevoked key can authenticate the same server.

Decide what the incident statement means. If one private host key leaked, the client must reject that key, while another independently protected host key may remain acceptable after review. If the machine itself lost integrity, revoke every host identity it could present, including certificates, and remove trust until the machine is rebuilt. Writing "host revoked" in a ticket without listing identities leaves this decision to algorithm negotiation.

Inventory the fixture with the daemon configuration and trusted records, not with a single scan. Test once per enabled host-key algorithm by constraining `HostKeyAlgorithms` to the algorithm under examination. The revoked key must fail. Every identity that is meant to survive should have its own positive case and documented fingerprint.

Host certificates add another distinction. You can revoke the certificate as a public object, revoke a certificate serial or key ID in a KRL, or revoke the CA that authorizes a class of hosts. Those choices have different blast radii. The OpenSSH `ssh-keygen` manual documents KRL directives for serials, key IDs, public keys, and fingerprints; query the finished KRL with the exact certificate file the server presents.

`UpdateHostKeys` also deserves attention. OpenSSH can learn additional server keys after a host authenticates with an already trusted plain key. That supports planned rotation, but previously learned alternates remain candidates unless the revocation decision accounts for them. Dump the test client's known-hosts records with `ssh-keygen -F` for each name and `[name]:port` form, then compare every public key with the incident scope.

## Run the negative test through the agent's real path

A terminal test under an engineer's account does not prove an autonomous agent uses the same SSH binary, configuration, home directory, resolver, proxy route, or control socket. The final suite must invoke the exact action boundary the agent invokes and must start with the same environment it receives in production.

Make the harness return structured evidence for each variant: destination label, resolved hostname and port, presented fingerprint, KRL digest, connection-sharing state, exit status, whether the sentinel appeared, and the phase at which the server closed the attempt. Keep secrets out of that record. The useful assertion is simple: the revoked identity appeared, the client recognized it as revoked, and no user authentication or command followed.

Test environment isolation can expose accidental dependencies. Set an explicit SSH config path, known-hosts file, KRL, identity file, and control path in the fixture. Clear or replace `SSH_AUTH_SOCK` if the agent must not borrow unrelated user keys. Bound connection time so CI does not hang. Preserve stderr and server logs as artifacts on failure, while the passing report can keep their hashes and the decisive lines.

If agents issue SSH through Sallyport, exercise that route in the negative test: its bundled stateless `sp-ssh` helper executes SSH actions, while the Sessions and Activity journals record the agent run and individual call. Verify the audit chain offline with `sp audit verify` when the test also covers evidence integrity; the host-key rejection still has to come from the SSH trust configuration exercised by the real action.

Do not weaken the client to make automation convenient. `StrictHostKeyChecking=no`, an empty known-hosts target, or discarding diagnostics to `/dev/null` turns a security test into a connectivity probe. `BatchMode=yes` removes prompts; it does not excuse missing trust material.

## A revocation test should fail for only one reason

Put the matrix in CI, but keep the fixture small enough to diagnose. Run a positive control with an allowed identity, preflight the KRL, start the listener, and then run fresh-connection variants. Exercise multiplexing in a separate job or phase because it has different setup and expected behavior. Finally, stop the listener and assert that no fixture master or socket remains.

Reject a build if any destination reaches the sentinel, if any effective configuration lacks the required revocation file, or if the server offers an identity the test inventory does not classify. Also reject an inconclusive run. A timeout or unreadable KRL is a broken test, even though the SSH command returned nonzero.

Keep the expected fingerprint and KRL digest in the test report. When rotation changes the fixture identity, the review should show both values changing together. That small friction prevents a common failure in which the server gets a new key while the test continues revoking an abandoned public-key file.

Run the suite with the agent process unable to prompt. An approval dialog, password request, or host-confirmation question can leave a job waiting until a generic timeout masks the cause. `BatchMode=yes` should make those paths fail immediately, while the positive control proves the fixture needs no interaction. Put a hard timeout around the outer job as a final guard, but never count that timeout as evidence of revocation.

Retain one sanitized verbose trace for each client version you support. It gives maintainers a reference for the order of configuration expansion, connection reuse, key exchange, host-key selection, and rejection. When an operating-system update changes OpenSSH behavior or wording, compare the new trace with that reference and update assertions only after the fingerprint and phase still agree. Security tests rot when teams normalize every new failure as harmless output drift.

The awkward result to preserve is the live-master case. It reminds responders that distributing a KRL is not session revocation. Close the existing transports, prove that the next attempt performs key exchange, and watch the client reject the exact fingerprint you marked. Only then have you shown that aliases, cached state, and address spelling cannot carry the agent back in.
