publickey-hostbound Authentication
OpenSSH 8.9 introduced publickey-hostbound-v00@openssh.com, an extension
to SSH public-key authentication that binds a login credential to a specific
server. SSH-MITM supports this method on the server side so that modern
OpenSSH clients can authenticate transparently through an interception proxy.
This page starts with the concept, builds up to the protocol details, and finishes with the MITM implications.
The problem with standard public-key authentication
When you log in to an SSH server with a public key, your SSH client signs a piece of data to prove it holds the matching private key. That data looks roughly like this:
session_id + username + "publickey" + your_public_key
Notice what is not in there: any information about which server you are connecting to. The signature only proves “I have this key” — not “I have this key and I intend to log in to this specific server.”
In practice this is usually fine, because each SSH connection has a unique
session_id (derived from a key-exchange shared secret), so a captured
signature cannot be trivially replayed. But it means the server’s identity
plays no cryptographic role in the authentication — it is not covered by the
signature at all.
What publickey-hostbound adds
publickey-hostbound-v00@openssh.com extends the signed data with the
server’s host key:
session_id + username + "publickey-hostbound-v00@openssh.com"
+ your_public_key + server_host_key ← new
Now the signature is only valid for this exact server. If an attacker intercepts the connection and presents a different host key, the signed blob will be different and the signature will not verify.
Think of it as the difference between signing a blank cheque (“pay to whoever presents this”) and signing a cheque made out to a specific recipient and bank account.
SSH Agent and destination constraints
Before explaining how SSH-MITM handles this, it helps to understand the SSH agent and how destination constraints build on top of it.
What is an SSH agent?
An SSH agent is a background process that holds your decrypted private keys in
memory. Instead of typing your passphrase every time you connect to a server,
you unlock the key once with ssh-add and the agent signs on your behalf
from then on.
Your SSH client communicates with the agent over a Unix socket
($SSH_AUTH_SOCK). When the client needs to prove it holds a private key,
it sends the to-be-signed data to the agent and the agent returns the
signature — the private key itself never leaves the agent.
See also: Quickstart
What are destination constraints?
OpenSSH 8.9 added the -h flag to ssh-add. It lets you tell the agent
where a key is allowed to be used:
# This key may only be used to log in to prod.example.com
ssh-add -h prod.example.com ~/.ssh/id_ed25519
The agent stores this constraint alongside the private key. From this point on
it will refuse to sign anything with that key unless it can verify that the
current SSH connection really goes to prod.example.com.
For the agent to verify this, two things must happen:
The client tells the agent about the connection using the
session-bindextension (see below).The server must support publickey-hostbound, so that the agent’s host key check is cryptographically meaningful.
If either is missing, the agent simply refuses to sign — and the login fails
with Permission denied.
Multi-hop forwarding
Destination constraints also work across forwarded agents. You can express an
allowed forwarding path with the > notation:
# Key may be forwarded through jumphost to reach prod
ssh-add -h "jumphost.example.com>user@prod.example.com" ~/.ssh/id_ed25519
The agent tracks each hop in the forwarding chain and verifies the full path before signing.
Constraint immutability
Once a key is loaded, its constraints cannot be changed. To add or modify constraints, remove the key first and re-add it:
ssh-add -d ~/.ssh/id_ed25519
ssh-add -h prod.example.com ~/.ssh/id_ed25519
How the three pieces fit together
The full mechanism relies on three protocol extensions that work together:
sequenceDiagram
participant U as User
participant A as ssh-agent
participant C as SSH Client
participant S as Server
U->>A: ssh-add -h prod.example.com key<br/>(stores constraint + resolves host key)
C->>S: TCP connect + SSH handshake (key exchange)
note over C,S: session_id established,<br/>server signs it with its host key
C->>A: session-bind(server_hostkey, session_id, server_sig)
note over A: records: this connection goes to server_hostkey
C->>S: USERAUTH_REQUEST (hostbound, probe — no signature yet)
S->>C: USERAUTH_PK_OK (key accepted)
C->>A: sign(session_id ‖ username ‖ pubkey ‖ server_hostkey)
A->>A: check session-bind ✓ check constraint ✓
A-->>C: signature
C->>S: USERAUTH_REQUEST (hostbound, with signature)
S->>C: USERAUTH_SUCCESS
Step 1 — constraint stored in the agent
ssh-add -h encodes the destination in the agent’s key record. The agent
resolves the hostname to a host key using your local known_hosts file and
stores that key fingerprint as the allowed destination.
Step 2 — session-bind
After the SSH handshake but before authentication, the client sends a
session-bind message to the agent. This message contains the server’s
host key, the session ID, and the server’s signature over the session ID (which
proves the server genuinely holds the corresponding private key).
The agent records this binding. It now knows: “this agent connection corresponds to a session with this specific server.”
Step 3 — constrained signing
When the client asks the agent to sign the authentication blob, the agent checks:
Is there a session-bind for this
session_id?Does the
server_host_keyin the blob match the binding?Does that host key match the destination constraint on the key?
Only if all three checks pass does the agent sign. An attacker presenting a different host key breaks check 2 and the agent refuses.
Technical protocol details
This section documents the exact wire formats for readers who need them.
Authentication request (wire format)
Standard publickey (RFC 4252 §7):
byte SSH2_MSG_USERAUTH_REQUEST
string username
string "ssh-connection"
string "publickey"
bool has_signature
string algorithm
string public_key_blob
[string signature]
publickey-hostbound:
byte SSH2_MSG_USERAUTH_REQUEST
string username
string "ssh-connection"
string "publickey-hostbound-v00@openssh.com"
bool has_signature
string algorithm
string public_key_blob
string server_host_key_blob ← extra field
[string signature]
Signed blob comparison
Standard publickey:
string session_identifier
byte SSH2_MSG_USERAUTH_REQUEST (50)
string username
string "ssh-connection"
string "publickey"
bool true
string algorithm
string public_key_blob
publickey-hostbound:
string session_identifier
byte SSH2_MSG_USERAUTH_REQUEST (50)
string username
string "ssh-connection"
string "publickey-hostbound-v00@openssh.com"
bool true
string algorithm
string public_key_blob
string server_host_key_blob ← binds signature to this server
Extension negotiation (EXT_INFO)
The server signals hostbound support in its first SSH2_MSG_EXT_INFO
message (RFC 8308), sent immediately after SSH2_MSG_NEWKEYS:
string "publickey-hostbound@openssh.com"
string "0"
An OpenSSH client that sees this extension prefers the hostbound method over
plain publickey for all subsequent authentication attempts.
Session-bind message
byte SSH_AGENTC_EXTENSION (0x1b)
string "session-bind@openssh.com"
string hostkey (server's public host key)
string session_identifier
string signature (server's signature over session_identifier)
bool is_forwarding
Destination constraint (stored in agent)
byte SSH_AGENT_CONSTRAIN_EXTENSION (0xff)
string "restrict-destination-v00@openssh.com"
constraint[]:
string from_hostname (empty for the originating client)
keyspec[] from_hostkeys
string to_username (empty = any)
string to_hostname
keyspec[] to_hostkeys
Agent signing decision
flowchart TD
A[Signing request received] --> B{session-bind recorded\nfor this session_id?}
B -- No --> FAIL[Refuse: no binding]
B -- Yes --> C{hostkey in blob matches\nbinding hostkey?}
C -- No --> FAIL2[Refuse: hostkey mismatch]
C -- Yes --> D{hostkey + username match\ndestination constraint?}
D -- No --> FAIL3[Refuse: constraint violation]
D -- Yes --> E{is_forwarding &&\nintermediate-hop\nconstraint?}
E -- Fail --> FAIL4[Refuse: path violation]
E -- Pass / not applicable --> OK[Sign and return signature]
How SSH-MITM implements publickey-hostbound
SSH-MITM patches Paramiko’s AuthHandler at startup so that clients using
the hostbound method can authenticate transparently.
Without this support, a client with ssh-add -h constraints would receive
Permission denied immediately — a clear signal that something is wrong
with the connection. With the support in place, the client authenticates
normally and the interception remains transparent.
The patch is applied in sshmitm/cli.py:
AuthHandler._parse_service_request = auth_handler.auth_handler_parse_service_request
AuthHandler._parse_userauth_request = auth_handler.auth_handler_parse_userauth_request
What the server-side patch does:
Advertises
publickey-hostbound@openssh.com=0in the firstEXT_INFOmessage (sent right afterNEWKEYS).When a hostbound request arrives, reads
server_host_key_blobfrom the packet and verifies it matches SSH-MITM’s own host key.Reconstructs the hostbound signed blob and verifies the client’s signature.
Sends a second
EXT_INFOafterSSH_MSG_SERVICE_ACCEPTwith refreshedserver-sig-algs— matching standard OpenSSH server behaviour.
The probe/auth two-phase flow is fully supported:
sequenceDiagram
participant C as Client
participant M as SSH-MITM
C->>M: SSH_MSG_SERVICE_REQUEST ("ssh-userauth")
M->>C: SSH_MSG_SERVICE_ACCEPT
M->>C: SSH2_MSG_EXT_INFO (server-sig-algs)
C->>M: USERAUTH_REQUEST (hostbound, sig=false) ← probe
M->>C: USERAUTH_PK_OK
C->>M: USERAUTH_REQUEST (hostbound, sig=true) ← auth
M->>C: USERAUTH_SUCCESS
MITM implications
The table below summarises what SSH-MITM can and cannot do depending on how the client has configured its keys.
Scenario |
MITM possible? |
Reason |
|---|---|---|
Regular key, no |
Yes |
Agent has no restrictions; SSH-MITM uses the forwarded agent to authenticate upstream. |
Key with |
Yes |
SSH-MITM’s host key is stored in |
Key with |
No |
The client rejects SSH-MITM’s host key before authentication begins
(fingerprint mismatch with |
Key with |
No |
The session-bind for the upstream connection uses the real server’s host key, which does not match SSH-MITM’s key in the constraint. The agent refuses to sign. |
The three scenarios in diagram form:
sequenceDiagram
participant C as Client (no -h constraint)
participant M as SSH-MITM
participant S as Real Server
C->>M: connect — accepts SSH-MITM host key
C->>M: authenticate with publickey-hostbound ✓
M->>S: connect (new session)
M->>S: authenticate via forwarded agent (no constraint → signs) ✓
note over C,S: full MITM — session intercepted
sequenceDiagram
participant C as Client (ssh-add -h realserver, key known)
participant M as SSH-MITM
C->>M: connect
note over C: host key mismatch — StrictHostKeyChecking blocks
C--xM: connection refused before authentication
note over C,M: MITM blocked at fingerprint check
sequenceDiagram
participant A as ssh-agent (constrained key)
participant C as Client
participant M as SSH-MITM
participant S as Real Server
C->>M: connect — accepted SSH-MITM key on first use (TOFU)
C->>A: session-bind(SSH-MITM host key, session_id_1)
C->>M: authenticate with publickey-hostbound ✓
M->>S: connect (new session, session_id_2)
M->>A: sign(... real server host key ...)
A->>A: real server key ≠ SSH-MITM key in constraint
A--xM: agent refuses to sign
note over M,S: upstream auth blocked — MITM incomplete
Note
The fundamental protection is always the host key fingerprint.
StrictHostKeyChecking (the default in modern OpenSSH) blocks the
connection before any authentication is attempted if the host key does not
match the stored one.
ARP-spoof honeypot scenario
A common audit use case combines ARP spoofing with a honeypot backend. In this scenario SSH-MITM is the declared destination from the client’s perspective:
ARP spoofing redirects the client to the SSH-MITM host.
The client connects for the first time and accepts SSH-MITM’s host key (TOFU — Trust On First Use). The key is stored in
known_hostsunder the target hostname.ssh-add -h targetserver.example.comresolvestargetserver.example.comviaknown_hostsand finds SSH-MITM’s key — that key becomes the constraint.On the next connection the session-bind uses SSH-MITM’s key, which matches the stored constraint → the agent signs.
SSH-MITM forwards the session to a honeypot using its own credentials. The client’s private key is never exposed upstream.
ssh-mitm server --listen-port 10022 \
--remote-host honeypot.internal --remote-port 22
In this scenario publickey-hostbound and ssh-add -h do not protect
the client, because SSH-MITM is the accepted destination. The protection only
works when the client already knows the real server’s key and
StrictHostKeyChecking rejects the fake one.
Testing
The following example verifies end-to-end that an OpenSSH client uses the hostbound method when connecting through SSH-MITM with a destination-constrained key.
Step 1 — start SSH-MITM and accept its host key:
ssh-mitm server --listen-port 10022 --remote-host localhost --remote-port 22 &
# First connection: accept and store SSH-MITM's host key
ssh -o StrictHostKeyChecking=no -p 10022 user@localhost exit
Step 2 — reload the key with a destination constraint:
ssh-add -d ~/.ssh/id_ecdsa
# ssh-add resolves [localhost]:10022 via known_hosts
# → stores SSH-MITM's host key as the allowed destination
ssh-add -h "[localhost]:10022" ~/.ssh/id_ecdsa
Step 3 — connect with strict checking and agent forwarding:
ssh -o StrictHostKeyChecking=yes -A -p 10022 user@localhost
SSH-MITM’s log confirms that the hostbound method was used:
Without the publickey-hostbound advertisement, the client would fall back
to plain publickey. The agent — holding only a destination-constrained
key — would refuse to sign (no session-bind was established for plain pubkey
auth), and the login would fail with Permission denied.
Reference
RFC 4252 §7 — Public Key Authentication Method
RFC 8308 — Extension Negotiation in the Secure Shell (SSH) Protocol
ssh-add(1),ssh_config(5)