The SSH Protocol
SSH secures remote logins with a binary packet protocol, a curve25519-sha256 key exchange, and an Ed25519 host key. Learn the handshake and the chacha20-poly1305@openssh.com cipher, with Python code that reproduces the IETF draft's own worked example byte for byte.
Pashalis Laoutaris 29 min read
Try it
Interactive SSH Protocol Visualizer
🔐 SSH
The SSH Protocol
Introduction
SSH is the protocol behind remote logins, file transfers, and tunnels on almost every server. Two sides that have never met agree on a session key over an untrusted network, and one side proves its identity with a long-term key pair. The connection then carries an encrypted, authenticated byte stream until either side closes it.
This guide follows the transport layer of SSH version 2, as defined by RFC 4253 and its companions. It builds the binary packet protocol, the algorithm negotiation message, a curve25519-sha256 key exchange, an Ed25519 host-key signature, the six-key derivation of section 7.2, and the chacha20-poly1305@openssh.com cipher, OpenSSH’s default since version 6.9. The last of these comes with a rare gift: the Internet-Draft that defines it contains a full worked example, byte for byte, from a logical packet to the final wire bytes. My code reproduces that example exactly.
Table of Contents
- History and Standards
- The Design in Brief
- The Binary Packet Protocol
- Algorithm Negotiation
- The curve25519-sha256 Key Exchange
- Host Authentication with ssh-ed25519
- Key Derivation
- The chacha20-poly1305@openssh.com Cipher
- Interactive Visualizer
- A Worked Example
- Python Implementation
- Where It Stands
- Limitations
- Security Status
- FAQ
- References
History and Standards
SSH-1 appeared in 1995, written by Tatu Ylönen after a password-sniffing attack hit the network of Helsinki University of Technology, where he worked. The protocol had design flaws, and the community replaced it with SSH-2. The IETF secsh working group turned SSH-2 into a set of RFCs in 2006. RFC 4251 defines the architecture and the data types, RFC 4252 the authentication protocol, RFC 4253 the transport layer that this guide covers, and RFC 4254 the connection protocol that multiplexes channels on top of it.
The transport layer is an open framework. RFC 4253 fixes the message flow and the key-derivation formula, but it leaves the key-exchange method, the host-key algorithm, and the cipher as negotiated names. Later RFCs plug in stronger choices. RFC 5656 and RFC 8731 add elliptic-curve and Curve25519 key exchange, and RFC 8709 adds Ed25519 host keys. The Internet-Draft draft-ietf-sshm-chacha20-poly1305 documents the chacha20-poly1305@openssh.com cipher, which OpenSSH added as an option in version 6.5, released in January 2014. None of the three is an RFC-numbered cipher or key-exchange method from the original 2006 set, and all three are commonly negotiated by a modern OpenSSH client and server.
The Design in Brief
A connection goes through four stages, in order.
- Version exchange. Each side sends one line of text identifying its protocol version and software.
- Algorithm negotiation. Each side sends an
SSH_MSG_KEXINITpacket listing the algorithms it supports, for key exchange, host keys, ciphers, and MACs. - Key exchange. The negotiated method runs, producing a shared secret
Kand an exchange hashH. The server proves its identity with a signature overH. - Encrypted traffic. Six keys are derived from
K,H, and a session identifier. From here on, every packet is encrypted and authenticated, and the connection protocol (RFC 4254) and the authentication protocol (RFC 4252) run inside it.
This guide stops at the end of stage 4, with one encrypted application message. It does not implement user authentication or channel multiplexing.
The Binary Packet Protocol
Before any key exchange, the two sides exchange one line each:
SSH-protoversion-softwareversion SP comments CR LF
RFC 4253 section 4.2 requires the string to start with SSH-2.0-, to contain no NUL byte, and to be at most 255 bytes including the final CR LF. The example in the RFC itself is SSH-2.0-billsSSH_3.6.3q3. My code accepts any identification string of this shape as an argument, since the two strings become part of the exchange hash later.
Every packet sent after the identification strings uses the binary packet protocol of section 6:
uint32 packet_length
byte padding_length
byte[n1] payload
byte[n2] random padding
byte[m] mac
packet_length counts the padding-length byte, the payload, and the padding, but not itself and not the MAC. Before any cipher is negotiated, the padding brings the total length (the length field, the padding-length byte, the payload, and the padding) to a multiple of 8 bytes, with at least 4 bytes of padding. My code builds this plaintext framing and checks the padding rule on a batch of payload sizes, though the visualizer itself starts the demonstration after negotiation, where chacha20-poly1305@openssh.com replaces this framing with its own scheme.
Algorithm Negotiation
Each side opens the key exchange by sending an SSH_MSG_KEXINIT packet, message type 20:
byte SSH_MSG_KEXINIT
byte[16] cookie
name-list kex_algorithms
name-list server_host_key_algorithms
name-list encryption_algorithms_client_to_server
name-list encryption_algorithms_server_to_client
name-list mac_algorithms_client_to_server
name-list mac_algorithms_server_to_client
name-list compression_algorithms_client_to_server
name-list compression_algorithms_server_to_client
name-list languages_client_to_server
name-list languages_server_to_client
boolean first_kex_packet_follows
uint32 0
A name-list is an SSH string holding comma-separated algorithm names. RFC 4251 section 5 defines it along with the other wire types: byte, boolean, uint32, uint64, string, and mpint, the last a two’s-complement, most-significant-byte-first integer with no unneeded leading 0x00 or 0xff byte. My code implements all of these and checks them against the RFC’s own worked examples for mpint and name-list.
The 16-byte cookie is random on both sides. Its purpose, stated in section 7.1, is to stop either side from being able to fully determine the keys in advance. The full, unparsed bytes of both SSH_MSG_KEXINIT packets, called I_C and I_S, feed into the exchange hash in the next step, which is why an attacker who tampers with the algorithm list is caught: the hash would no longer match.
My code negotiates nothing. It builds one SSH_MSG_KEXINIT packet for each side with a fixed set of algorithms: curve25519-sha256 for key exchange, ssh-ed25519 for the host key, and chacha20-poly1305@openssh.com for both the cipher and the MAC. That cipher provides its own integrity tag and needs no separate MAC algorithm.
The curve25519-sha256 Key Exchange
RFC 5656 section 4 defines a generic elliptic-curve Diffie-Hellman exchange with two messages, SSH_MSG_KEX_ECDH_INIT (type 30) and SSH_MSG_KEX_ECDH_REPLY (type 31). RFC 8731 reuses the same two messages for Curve25519, with the X25519 function of RFC 7748 in place of generic curve arithmetic.
byte SSH_MSG_KEX_ECDH_INIT
string Q_C
byte SSH_MSG_KEX_ECDH_REPLY
string K_S
string Q_S
string signature
Q_C and Q_S are the 32-byte X25519 public keys, and K_S is the server’s host-key blob. The client sends Q_C first. The server computes the shared secret, signs the exchange hash with its host key, and replies with its host key, Q_S, and the signature.
The exchange hash itself, defined in RFC 4253 section 8 and reused unchanged by RFC 5656 and RFC 8731, is:
H = SHA-256(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K)
Each of V_C, V_S, I_C, I_S, K_S, Q_C, and Q_S is encoded as an SSH string, with its own length prefix. RFC 8731 section 3.1 gives the one encoding rule that is specific to Curve25519. The raw 32-byte X25519 output is reinterpreted as an unsigned big-endian integer and then encoded as a signed mpint, even though X25519 itself works with little-endian byte strings internally. This step is a common interoperability pitfall. Leading zero bytes must be dropped, and a first byte of 0x80 or more needs an extra 0x00 in front. Get either wrong, or read the bytes little-endian, and the exchange hash silently differs from the peer’s. My code follows this conversion exactly, and the exchange hash that the client computes from its own copy of Q_S equals the one the server computed, in every run.
Host Authentication with ssh-ed25519
RFC 8709 defines the wire format for an Ed25519 host key and its signature, both as plain 32-byte and 64-byte strings with no further structure, unlike the ASN.1 encodings that older key types use:
string "ssh-ed25519"
string public key, 32 bytes
string "ssh-ed25519"
string signature, 64 bytes
Ed25519 itself is RFC 8032’s signature scheme over the twisted Edwards curve edwards25519. My implementation follows the RFC’s formulas directly: point addition and doubling in extended homogeneous coordinates, the clamp operation on the private scalar, and the specific square-root computation of section 5.1.3 for decoding a compressed point. The server signs the exchange hash H with its long-term Ed25519 private key, and the client verifies the signature against the public key it received in K_S. This proves the server controls the private key without ever transmitting it.
Key Derivation
RFC 4253 section 7.2 derives six keys from the shared secret K, the exchange hash H, and a session identifier, which is the exchange hash of the very first key exchange on the connection:
Initial IV client to server: HASH(K || H || "A" || session_id)
Initial IV server to client: HASH(K || H || "B" || session_id)
Encryption key client to server: HASH(K || H || "C" || session_id)
Encryption key server to client: HASH(K || H || "D" || session_id)
Integrity key client to server: HASH(K || H || "E" || session_id)
Integrity key server to client: HASH(K || H || "F" || session_id)
K is encoded as a signed mpint, exactly as it was for the exchange hash. If a key needs more bytes than one hash output gives, the RFC extends it: K2 = HASH(K || H || K1), K3 = HASH(K || H || K1 || K2), and so on. The key is then as many bytes as needed from the front of the concatenation. My code implements this extension and checks that it produces 64 bytes per letter when chacha20-poly1305@openssh.com needs that many, which it does, since that cipher takes two 32-byte subkeys from each direction’s single key.
On the very first key exchange, session_id equals H itself. A later re-exchange produces a new H but keeps the original session_id, since authentication signatures from the first exchange must stay valid for the lifetime of the connection. My code only covers the first exchange, so H and session_id are the same value throughout.
The chacha20-poly1305@openssh.com Cipher
OpenSSH added this cipher in version 6.5, released in January 2014, years before the IETF’s own draft, which documents the already-deployed construction rather than defining a new one. As of revision 04 in May 2026, that draft is still an Internet-Draft, not an RFC. It asks IANA to register the plain name chacha20-poly1305 as identical to chacha20-poly1305@openssh.com, which remains the name on the wire today. The cipher needs no separate MAC algorithm, since Poly1305 already authenticates the packet, and RFC 4253’s key-derivation formula gives it two 32-byte keys per direction, K_1 and K_2, by asking for 64 bytes from letters C and D.
K_2 encrypts only the 4-byte packet-length field, with its own ChaCha20 block at counter 0 and a nonce built from the packet sequence number. This lets a receiver learn how many bytes to read before it can even check the MAC. The draft notes that the separate key also blocks a known attack (CVE-2008-5161). Tampering with the length field only reveals something about the length cipher, never about the payload cipher. K_1 encrypts the rest of the packet, starting at block counter 1, and a block of K_1 at counter 0 generates the one-time Poly1305 key. The nonce is the sequence number, encoded as 4 zero bytes followed by the 8-byte big-endian sequence number. ChaCha20’s own convention then decodes it back as little-endian, which is why the draft calls out that the sequence number appears endianness-swapped in its own diagrams.
nonce = 0x00000000 || sequence_number (8 bytes, big-endian)
length_stream = ChaCha20(K_2, counter=0, nonce)
enc_length = length_field XOR length_stream[0:4]
payload_stream = ChaCha20(K_1, counter=1, nonce)
enc_payload = plaintext XOR payload_stream
mac_key = ChaCha20(K_1, counter=0, nonce)[0:32]
tag = Poly1305(mac_key, enc_length || enc_payload)
The plaintext that gets encrypted is the ordinary binary-packet-protocol body: a padding-length byte, the payload, and the padding, but without the usual separate length field, since that field is encrypted on its own with K_2. My code implements both directions, seals and opens packets, and verifies the MAC before decrypting the payload, exactly as the draft specifies, since checking the MAC first avoids decrypting attacker-controlled bytes with an unauthenticated key stream.
Interactive Visualizer
The visualizer above runs one full exchange: the version strings, both SSH_MSG_KEXINIT packets, SSH_MSG_KEX_ECDH_INIT, SSH_MSG_KEX_ECDH_REPLY, the client’s verification of the signature, the six derived keys, one SSH_MSG_CHANNEL_DATA packet encrypted with chacha20-poly1305@openssh.com, and the server decrypting it.
The fields are a seed for the keys, the client and server identification strings, the message to send, and a damage option.
- No damage. The exchange completes, and the final line shows the message the server read back.
- Bad signature. The server’s signature on the exchange hash is flipped by one bit before it is sent. The client’s verification fails, and the log stops there.
- Tampered reply. The server’s ephemeral public key is flipped by one bit after it was used to compute the exchange hash, but before it is sent. The client computes a different exchange hash from the one the server signed, so the signature no longer verifies.
- Tampered packet. The last byte of the encrypted channel-data packet is flipped. The server’s Poly1305 check fails, and the packet is rejected.
- The wrong direction’s keys. The packet is sealed with the server-to-client keys instead of the client-to-server keys. The server, which expects the client-to-server keys, rejects it.
The default seed and identification strings match the ones used in the worked example below, so the log’s first run reproduces the same bytes that this page prints. The page has its own SHA-512, Ed25519, ChaCha20, Poly1305, and X25519.
A Worked Example
With the seed page-default and the identification strings SSH-2.0-ExampleClient_1.0 and SSH-2.0-ExampleServer_1.0, the exchange produces these ephemeral public keys and this host key.
| Value | Bytes |
|---|---|
Client ephemeral public key Q_C |
3b8a4ae4d0ad56cf5b2d295241bd131b29ad31595800c5537ff035b898b80f41 |
Server ephemeral public key Q_S |
893d0c5943ff03ba7195f811f142c50ef404cdab16daa851a4e603373423a320 |
| Server host public key | 5c3b9cda05a72ae9e62bd5909f9d966a042036ad8f2864e84b682efecd50b041 |
Both SSH_MSG_KEXINIT packets are 164 bytes. The server’s signature on the exchange hash is e886356f1150f7d98621ff7cc1aa7863bc201a56f0db032da5b21c5a27cafdfa65838479adc17504da239ab5cf876683cd5e1a931c7427e8cc40e94f953df504, carried inside an SSH_MSG_KEX_ECDH_REPLY message of 179 bytes. Both sides compute the same exchange hash:
dd04a9530e026124520799cce11a752e92c3eca722994f9b4ac70a9d5ed1b70e
Six keys of 64 bytes each are derived from this hash. The client-to-server encryption key starts with 82f352698cacea3948a107fc4fc8a8a4, and the server-to-client one starts with 2cc157f999937ea6c64b8f45778b91a9. An SSH_MSG_CHANNEL_DATA message carrying the text “hello from the client” is sealed into a packet of 55 bytes, starting with efce42bc588e441198ad97e3e7932e97, and the server’s decryption of it recovers the message unchanged.
The draft’s own worked example
The Internet-Draft for chacha20-poly1305@openssh.com includes a complete example in its appendix, independent of any key exchange, which my code reproduces exactly. A 64-byte key block starting with 8bbff6855fc102338c373e73aac0c914 is split into K_1 and K_2. The logical input packet is an SSH_MSG_CHANNEL_DATA message of 65 bytes carrying the text “Lorem ipsum dolor sit amet, consectetur adipisicing elit”. The draft pads it to 72 bytes with 6 bytes of padding before encryption, and sends it with the sequence number 7.
The length field encrypts to 2c3ecce4, and the full wire output, length field, encrypted payload, and Poly1305 tag together, is 92 bytes, ending in the tag 95349e855bf02c298ef775f2d1a7e8b8:
2c3ecce4a5bc05895bf07a7ba956b6c68829ac7c83b780b7000ecde745afc705bbc378ce03a280236b87b53bed5839662302b164b6286a48cd1e097138e3cb909b8b2b829dd18d2a35ff82d995349e855bf02c298ef775f2d1a7e8b8
This is not a value my code computed and then checked for self-consistency. It is the draft’s own published result, and my implementation produces the identical bytes from the same inputs.
Python Implementation
The first file is Ed25519 from RFC 8032, used for the host-key signature.
# ed25519.py
# Ed25519 (RFC 8032, section 5.1): key generation, signing, and verification, with extended homogeneous
# coordinates for point arithmetic (section 5.1.4). Used here for SSH host-key authentication (ssh-ed25519).
# Teaching code. It is not constant time.
import hashlib
P = 2 ** 255 - 19
L = 2 ** 252 + 27742317777372353535851937790883648493
D = (-121665 * pow(121666, P - 2, P)) % P
BY = 46316835694926478169428394003475163141307993866256225615783033603165251855960
BX = 15112221349535400772501151409588531511454012693041857206046113283949847762202
IDENTITY = (0, 1, 1, 0) # extended coordinates (X, Y, Z, T)
def sha512(data):
return hashlib.sha512(data).digest()
def inv(x):
return pow(x, P - 2, P)
def point_add(p1, p2):
x1, y1, z1, t1 = p1
x2, y2, z2, t2 = p2
a = (y1 - x1) * (y2 - x2) % P
b = (y1 + x1) * (y2 + x2) % P
c = t1 * 2 * D % P * t2 % P
d = z1 * 2 % P * z2 % P
e = (b - a) % P
f = (d - c) % P
g = (d + c) % P
h = (b + a) % P
return (e * f % P, g * h % P, f * g % P, e * h % P)
def point_double(p):
return point_add(p, p)
def scalar_mult(s, point):
result = IDENTITY
addend = point
while s:
if s & 1:
result = point_add(result, addend)
addend = point_double(addend)
s >>= 1
return result
def to_affine(p):
x, y, z, _ = p
zi = inv(z)
return (x * zi % P, y * zi % P)
BASE = (BX, BY, 1, BX * BY % P)
def encode_point(p):
x, y = to_affine(p)
out = bytearray(y.to_bytes(32, "little"))
out[31] |= (x & 1) << 7
return bytes(out)
ROOT_OF_MINUS_ONE = pow(2, (P - 1) // 4, P)
def decode_point(data):
# RFC 8032 section 5.1.3: recover x from x^2 = (y^2 - 1) / (d*y^2 + 1), using the single-exponentiation
# trick of section 5.1.3 for both the inversion of v and the square root
assert len(data) == 32
y = int.from_bytes(data, "little")
sign = (y >> 255) & 1
y &= (1 << 255) - 1
if y >= P:
raise ValueError("y out of range")
u = (y * y - 1) % P
v = (D * y * y + 1) % P
x = u * pow(v, 3, P) % P * pow(u * pow(v, 7, P), (P - 5) // 8, P) % P
if (v * x * x - u) % P == 0:
pass # x is already a square root
elif (v * x * x + u) % P == 0:
x = x * ROOT_OF_MINUS_ONE % P
else:
raise ValueError("not a valid point")
if x == 0 and sign == 1:
raise ValueError("invalid encoding of the identity")
if (x & 1) != sign:
x = P - x
return (x, y, 1, x * y % P)
def clamp(h32):
s = bytearray(h32)
s[0] &= 248
s[31] &= 127
s[31] |= 64
return int.from_bytes(bytes(s), "little")
def public_key(private_key):
h = sha512(private_key)
s = clamp(h[:32])
return encode_point(scalar_mult(s, BASE))
def sign(private_key, message):
h = sha512(private_key)
s = clamp(h[:32])
prefix = h[32:]
a = encode_point(scalar_mult(s, BASE))
r = int.from_bytes(sha512(prefix + message), "little") % L
r_enc = encode_point(scalar_mult(r, BASE))
k = int.from_bytes(sha512(r_enc + a + message), "little") % L
s_scalar = (r + k * s) % L
return r_enc + s_scalar.to_bytes(32, "little")
def verify(public_key_bytes, message, signature):
if len(signature) != 64:
return False
r_enc, s_enc = signature[:32], signature[32:]
s_scalar = int.from_bytes(s_enc, "little")
if s_scalar >= L:
return False
try:
r_point = decode_point(r_enc)
a_point = decode_point(public_key_bytes)
except ValueError:
return False
k = int.from_bytes(sha512(r_enc + public_key_bytes + message), "little") % L
lhs = scalar_mult(s_scalar, BASE)
rhs = point_add(r_point, scalar_mult(k, a_point))
return to_affine(lhs) == to_affine(rhs)
The second file is the SSH transport layer: the wire types of RFC 4251, the plaintext binary packet protocol, and SSH_MSG_KEXINIT. It also has the curve25519-sha256 key exchange, the exchange hash, ssh-ed25519 host key and signature blobs, the key-derivation formula of section 7.2, and chacha20-poly1305@openssh.com. The X25519 and ChaCha20-Poly1305 functions it calls come from the Noise guide.
# ssh_transport.py
# The SSH transport layer (RFC 4253), the curve25519-sha256 key exchange (RFC 5656 section 4, RFC 8731),
# the key derivation function (RFC 4253 section 7.2), ssh-ed25519 host authentication (RFC 8709), and the
# chacha20-poly1305@openssh.com cipher (draft-ietf-sshm-chacha20-poly1305).
# Teaching code. It is not constant time, and every random value (keys, nonces, padding) comes from the caller.
import hashlib
import chacha_poly as cp
import dh
import ed25519 as ed
MSG_KEXINIT = 20
MSG_NEWKEYS = 21
MSG_KEX_ECDH_INIT = 30
MSG_KEX_ECDH_REPLY = 31
# ---------------------------------------------------------------- RFC 4251 section 5: the wire data types
def ssh_uint32(n):
return n.to_bytes(4, "big")
def ssh_string(data):
return ssh_uint32(len(data)) + data
def ssh_name_list(names):
return ssh_string(",".join(names).encode())
def ssh_mpint(n):
# RFC 4251 section 5: two's complement, MSB first, no unnecessary leading 0x00 or 0xff byte
if n == 0:
return ssh_string(b"")
if n > 0:
length = (n.bit_length() + 8) // 8 # one more bit for the sign, rounded up to a byte
body = n.to_bytes(length, "big")
else:
length = 1
while True:
try:
body = n.to_bytes(length, "big", signed=True)
break
except OverflowError:
length += 1
return ssh_string(body)
def read_uint32(data, pos):
return int.from_bytes(data[pos:pos + 4], "big"), pos + 4
def read_string(data, pos):
length, pos = read_uint32(data, pos)
return data[pos:pos + length], pos + length
def unsigned_mpint(n_bytes):
# Curve25519's shared secret, reinterpreted as an unsigned big-endian integer, then mpint-encoded (RFC 8731 section 3.1)
return int.from_bytes(n_bytes, "big")
# ---------------------------------------------------------------- the binary packet protocol (RFC 4253 section 6)
def encode_packet_plaintext(payload, block_size=8, padding=None):
# with no cipher negotiated yet: total length a multiple of block_size (or 8), at least 4 bytes of padding
unpadded = 1 + len(payload) # the padding-length byte plus the payload
pad_len = block_size - (unpadded % block_size)
if pad_len < 4:
pad_len += block_size
pad = padding if padding is not None else bytes(pad_len)
assert len(pad) == pad_len
body = bytes([pad_len]) + payload + pad
return ssh_uint32(len(body)) + body
def decode_packet_plaintext(data):
length, pos = read_uint32(data, 0)
pad_len = data[pos]
payload = data[pos + 1:pos + length - pad_len]
return payload
# ---------------------------------------------------------------- KEXINIT (RFC 4253 section 7.1)
def build_kexinit(cookie, kex_algorithms, host_key_algorithms, enc_c2s, enc_s2c, mac_c2s, mac_s2c,
comp_c2s=("none",), comp_s2c=("none",), lang_c2s=(), lang_s2c=(), first_kex_packet_follows=False):
assert len(cookie) == 16
return (bytes([MSG_KEXINIT]) + cookie +
ssh_name_list(kex_algorithms) + ssh_name_list(host_key_algorithms) +
ssh_name_list(enc_c2s) + ssh_name_list(enc_s2c) +
ssh_name_list(mac_c2s) + ssh_name_list(mac_s2c) +
ssh_name_list(comp_c2s) + ssh_name_list(comp_s2c) +
ssh_name_list(lang_c2s) + ssh_name_list(lang_s2c) +
bytes([1 if first_kex_packet_follows else 0]) + ssh_uint32(0))
# ---------------------------------------------------------------- curve25519-sha256 key exchange (RFC 5656 section 4, RFC 8731)
def host_key_blob_ed25519(public_key):
return ssh_string(b"ssh-ed25519") + ssh_string(public_key)
def signature_blob_ed25519(signature):
return ssh_string(b"ssh-ed25519") + ssh_string(signature)
def kex_ecdh_init(client_ephemeral_public):
return bytes([MSG_KEX_ECDH_INIT]) + ssh_string(client_ephemeral_public)
def kex_ecdh_reply(host_key_blob, server_ephemeral_public, signature_blob):
return bytes([MSG_KEX_ECDH_REPLY]) + ssh_string(host_key_blob) + ssh_string(server_ephemeral_public) + ssh_string(signature_blob)
def exchange_hash(v_c, v_s, i_c, i_s, k_s, q_c, q_s, shared_secret):
# H = HASH(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K), each field as an SSH string except K as mpint
k = unsigned_mpint(shared_secret)
data = (ssh_string(v_c) + ssh_string(v_s) + ssh_string(i_c) + ssh_string(i_s) +
ssh_string(k_s) + ssh_string(q_c) + ssh_string(q_s) + ssh_mpint(k))
return hashlib.sha256(data).digest()
# ---------------------------------------------------------------- key derivation (RFC 4253 section 7.2)
def derive_keys(shared_secret, h, session_id, lengths):
# lengths: a dict from "A".."F" to the number of bytes needed; returns a dict with the same keys
k = ssh_mpint(unsigned_mpint(shared_secret))
out = {}
for letter, length in lengths.items():
material = hashlib.sha256(k + h + letter.encode() + session_id).digest()
while len(material) < length:
material += hashlib.sha256(k + h + material).digest()
out[letter] = material[:length]
return out
# ---------------------------------------------------------------- chacha20-poly1305@openssh.com
def chacha_seal_packet(k1, k2, sequence_number, payload, pad_len_choice=None, padding=None):
unpadded = 1 + len(payload)
pad_len = pad_len_choice if pad_len_choice is not None else max(4, 8 - (unpadded % 8))
if pad_len < 4:
pad_len += 8
pad = padding if padding is not None else bytes(pad_len)
assert len(pad) == pad_len
plaintext = bytes([pad_len]) + payload + pad
length_field = ssh_uint32(len(plaintext))
nonce = bytes(4) + sequence_number.to_bytes(8, "big")
length_stream = cp.chacha20_block(k2, 0, nonce)
enc_length = bytes(a ^ b for a, b in zip(length_field, length_stream[:4]))
enc_payload = cp.chacha20_xor(k1, 1, nonce, plaintext)
mac_key_block = cp.chacha20_block(k1, 0, nonce)
tag = cp.poly1305(mac_key_block[:32], enc_length + enc_payload)
return enc_length + enc_payload + tag
def chacha_open_length(k2, sequence_number, enc_length):
nonce = bytes(4) + sequence_number.to_bytes(8, "big")
length_stream = cp.chacha20_block(k2, 0, nonce)
length_field = bytes(a ^ b for a, b in zip(enc_length, length_stream[:4]))
return int.from_bytes(length_field, "big")
def chacha_open_packet(k1, k2, sequence_number, wire_bytes):
enc_length, rest = wire_bytes[:4], wire_bytes[4:]
plain_len = chacha_open_length(k2, sequence_number, enc_length)
enc_payload, tag = rest[:plain_len], rest[plain_len:plain_len + 16]
nonce = bytes(4) + sequence_number.to_bytes(8, "big")
mac_key_block = cp.chacha20_block(k1, 0, nonce)
expected_tag = cp.poly1305(mac_key_block[:32], enc_length + enc_payload)
if not cp.constant_time_equal(tag, expected_tag):
raise ValueError("MAC verification failed")
plaintext = cp.chacha20_xor(k1, 1, nonce, enc_payload)
pad_len = plaintext[0]
return plaintext[1:len(plaintext) - pad_len]
The test file runs every check that follows.
mpint: all 5 worked examples of RFC 4251 section 5
name-list: all 3 worked examples of RFC 4251 section 5
chacha20-poly1305@openssh.com: the full worked example of the draft's appendix A is reproduced byte for byte,
including the encrypted length field, the encrypted payload, and the Poly1305 tag
the same packet decrypts back to the original payload, with the MAC verified first
a changed MAC byte is rejected
40 random messages round-trip through chacha20-poly1305@openssh.com, and a changed byte anywhere in the packet is rejected
the plaintext binary packet protocol pads every packet to a multiple of 8 bytes with at least 4 bytes of padding
curve25519-sha256: the client and the server compute the same exchange hash H from the same transcript
the server's signature on H, made with its Ed25519 host key, verifies on the client's own copy of H
SSH_MSG_KEX_ECDH_INIT is 37 bytes and SSH_MSG_KEX_ECDH_REPLY is 179 bytes
key derivation (RFC 4253 section 7.2): both sides derive the same six keys (A through F), and all six differ
a channel-data message, encrypted with the session keys derived above, decrypts correctly for 5 sequence numbers
a packet sealed with the client-to-server keys is rejected by the server-to-client keys, as it must be
I checked the code in layers.
- Ed25519. My implementation matches all 4 test vectors of RFC 8032 section 7.1, for key generation, signing, and verification, and 100 random (key, message) pairs sign and verify correctly, with a changed signature byte always rejected.
- The wire types. The
mpintandname-listencodings match the worked examples given in RFC 4251 section 5. - chacha20-poly1305@openssh.com against the draft. My code reproduces the Internet-Draft’s own worked example byte for byte, as shown above, and 40 random messages round-trip through it correctly, with a changed byte anywhere in the packet always rejected.
- A full handshake. In a simulated curve25519-sha256 exchange, the client and the server independently compute the same exchange hash from the same transcript. The server’s Ed25519 signature on that hash verifies, and all six derived keys differ from each other. A channel-data message encrypted with the derived keys decrypts correctly for 5 consecutive sequence numbers, and a packet sealed with the wrong direction’s keys is rejected, as it must be.
The page code has its own versions of these functions. Its SHA-512 matches Node’s crypto module on 28 random inputs, and its Ed25519 matches all 4 RFC 8032 vectors. Its own handshake reproduces the same exchange hash, the same verified signature, the same six keys, and the same channel-data round trip as the Python code. Across 7 CIPHER-level scenarios, including a bad signature, a tampered ephemeral key, a tampered packet, and the wrong direction’s keys, the page’s log matches the Python reference message for message, and all 7 of them agree on the final outcome. I ran the page in a simulated browser.
What I did not check against an official source is the handshake as a whole, since no RFC or draft publishes end-to-end vectors for a full curve25519-sha256 exchange with an Ed25519 host key. That part is checked by the two independent implementations, Python and JavaScript, agreeing with each other on every byte, and by the RFC’s own formulas, piece by piece. I did not run an interoperability test against a real SSH client or server.
Where It Stands
- The world’s remote-login protocol. Nearly every server reachable over the internet accepts SSH connections for administration, and it is the transport underneath
git,scp,sftp, and countless automation tools. - A layered design. The transport layer in this guide is only the foundation. The authentication protocol of RFC 4252 and the connection protocol of RFC 4254, which multiplexes shells, port forwards, and file transfers over one encrypted channel, both run on top of it.
- A different design from WireGuard. The WireGuard guide covers a protocol with no negotiation at all: every algorithm is fixed, and a peer is a bare public key. SSH negotiates host-key types, key-exchange methods, and ciphers on every connection.
- Comparable to TLS. The TLS guide covers another protocol that negotiates algorithms and authenticates a server with a signature, though TLS usually relies on a certificate chain instead of a bare host key that the client must verify itself.
Limitations
This is a teaching implementation of the transport layer only, not a production SSH client or server:
- Not constant time. The Python code branches on secret values and uses variable-size integers for Curve25519 and Ed25519 arithmetic.
- One key-exchange method, one host-key type, one cipher. No
diffie-hellman-group14-sha1fallback, no RSA or ECDSA host keys, and no separate MAC algorithms for a non-AEAD cipher. - No negotiation logic. Both sides are built to agree on the one supported algorithm in each category. The real tie-breaking rules of RFC 4253 section 7.1, for when the two sides list several options, are not implemented.
- No connection protocol. Channels, port forwarding, and the authentication methods of RFC 4252 and RFC 4254 are out of scope. The demonstration stops after one encrypted channel-data message.
- No re-exchange, no compression, no certificates. The session identifier is always the hash of the first key exchange, since no second key exchange ever runs.
- No sockets. The code builds and reads messages in memory. It does not open a TCP connection or run a state machine.
Security Status
The transport layer this guide covers has held up well since 2006, with the main public weaknesses concentrated elsewhere.
- Terrapin, 2023. Fabian Bäumer, Marcus Brinkmann, and Jörg Schwenk of Ruhr University Bochum found a prefix-truncation attack. A man in the middle injects extra messages before encryption starts, then deletes the same number right after it starts. Sequence numbers still line up, so neither side notices. It works against chacha20-poly1305@openssh.com, the cipher this guide implements, and against CBC with encrypt-then-MAC.
- Strict key exchange. OpenSSH 9.6 answered with a “strict KEX” extension. It ends the connection on any unexpected message during the first key exchange, and it resets sequence numbers after each
SSH_MSG_NEWKEYS. The draft says implementations must do both. This guide’s code does neither, so it is not a model of a post-Terrapin implementation. - The historical CBC and RC4 ciphers. Padding-oracle and keystream-bias attacks against the cipher suites of the original 2006 RFCs pushed implementations toward AEAD ciphers, chacha20-poly1305@openssh.com among them, and away from separate MAC algorithms layered on a non-authenticated cipher.
- Host-key verification is the client’s job. RFC 4253 section 8 states plainly that a client may accept a host key without verification, at the cost of security against an active attacker. The common
known_hoststrust-on-first-use model is a client policy, not a transport-layer guarantee. - Curve25519 and Ed25519. Both rest on Daniel J. Bernstein’s widely reviewed designs, chosen in RFC 8731 and RFC 8709 specifically for their resistance to the implementation pitfalls of earlier elliptic-curve parameter choices.
FAQ
Why does OpenSSH use chacha20-poly1305@openssh.com instead of a standard AEAD cipher?
AES-GCM came first, in RFC 5647 (2009) and as aes256-gcm@openssh.com in OpenSSH 6.2. But RFC 5647 leaves the packet length unencrypted, as associated data, which reveals every packet’s size to an observer. The ChaCha20 construction encrypts the length with its own key, K_2. This hides the length and keeps any attack on that field away from the payload cipher.
Does SSH use certificates like TLS?
Not by default, no. A host key is just a public key, and the client is responsible for deciding whether to trust it, typically by checking a locally stored known_hosts file. OpenSSH also supports an optional certificate authority scheme, which is a separate extension and not part of RFC 4253 or the key types this guide implements.
What is the session identifier for?
It is the exchange hash of the very first key exchange on a connection. RFC 4253 section 7.2 uses it, unchanged, in the key-derivation formula for every later key exchange on the same connection, and the authentication protocol signs data that includes it, so a signature from one connection cannot be replayed on another.
Why are there two different key-exchange messages in RFC 5656 and RFC 8731 if they do the same thing?
They do not do exactly the same thing. RFC 5656 defines the generic message format for any elliptic-curve Diffie-Hellman method, including Menezes-Qu-Vanstone variants not covered here. RFC 8731 reuses that same message format for Curve25519 and Curve448, but changes one detail: how the raw output of the X25519 or X448 function is converted into the mpint that feeds the exchange hash and the key-derivation formula.
Could a passive eavesdropper read an SSH session recorded today, later?
Not from the key exchange itself. Curve25519 provides forward secrecy, since the ephemeral private keys are discarded after use, so recording the encrypted traffic and later obtaining the long-term host key does not reveal the session keys. An eavesdropper would need to break the discrete-logarithm problem on Curve25519 directly.
References
-
RFC 4251, “The Secure Shell (SSH) Protocol Architecture,” by Ylönen and Lonvick, January 2006. Available at: https://www.rfc-editor.org/rfc/rfc4251
-
RFC 4253, “The Secure Shell (SSH) Transport Layer Protocol,” by Ylönen and Lonvick, January 2006. Available at: https://www.rfc-editor.org/rfc/rfc4253
-
RFC 4254, “The Secure Shell (SSH) Connection Protocol,” by Ylönen and Lonvick, January 2006. Available at: https://www.rfc-editor.org/rfc/rfc4254
-
RFC 5656, “Elliptic Curve Algorithm Integration in the Secure Shell Transport Layer,” by Stebila and Green, December 2009. Available at: https://www.rfc-editor.org/rfc/rfc5656
-
RFC 8731, “Secure Shell (SSH) Key Exchange Method Using Curve25519 and Curve448,” by Adamantiadis, Josefsson, and Baushke, February 2020. Available at: https://www.rfc-editor.org/rfc/rfc8731
-
RFC 8709, “Ed25519 and Ed448 Public Key Algorithms for the Secure Shell (SSH) Protocol,” by Harris and Velvindron, February 2020. Available at: https://www.rfc-editor.org/rfc/rfc8709
-
RFC 8032, “Edwards-Curve Digital Signature Algorithm (EdDSA),” by Josefsson and Liusvaara, January 2017. Available at: https://www.rfc-editor.org/rfc/rfc8032
-
Miller, Tatham, and Josefsson. “Secure Shell (SSH) authenticated encryption cipher: chacha20-poly1305,”
draft-ietf-sshm-chacha20-poly1305-04, 26 May 2026. Available at: https://datatracker.ietf.org/doc/draft-ietf-sshm-chacha20-poly1305/ -
Bäumer, Brinkmann, and Schwenk. “Terrapin Attack: Breaking SSH Channel Integrity By Sequence Number Manipulation,” Ruhr University Bochum, CVE-2023-48795, 2023. Available at: https://terrapin-attack.com/
-
RFC 5647, “AES Galois Counter Mode for the Secure Shell Transport Layer Protocol,” by Igoe and Solinas, August 2009. Available at: https://www.rfc-editor.org/rfc/rfc5647
-
OpenSSH 9.6 release notes, describing the strict KEX countermeasure to Terrapin, December 2023. Available at: https://www.openssh.com/txt/release-9.6