zig-ssh
A focused, zero-dependency SSH server library for Zig. It implements the modern, secure subset of the protocol needed to authenticate a client, receive a command, and return a result — nothing more.
Details
Philosophy
SSH is a large protocol with decades of accumulated surface area. zig-ssh cuts it to the core that is actually useful for building command-dispatch servers. Every omitted feature is a deliberate choice. The library has no client role, no interactive sessions, no password auth, no port forwarding, no SFTP, no compression, no X11. What remains is a tight, auditable implementation of exactly one use case, done correctly.
There are no dependencies. The build manifest's .dependencies field is empty. All cryptography comes from Zig's standard library.
Protocol
Each accepted connection runs on its own thread and walks a strict linear state machine:
VERSION → KEXINIT → ECDH → NEWKEYS → SERVICE → AUTH → CHANNEL → EXEC → CLOSED
State cannot advance until the current step succeeds. Any protocol error at any stage sends SSH_MSG_DISCONNECT with an RFC-correct reason code and tears the connection down cleanly. There is no partial state left behind.
Cryptography
Only modern algorithms are supported. Legacy cipher suites are not negotiable.
| Layer | Algorithm | Reference | | ---------------- | ------------------------------------ | ---------- | | Key exchange | curve25519-sha256 | RFC 8731 | | Host identity | ssh-ed25519 | RFC 8709 | | Encryption + MAC | chacha20-poly1305@openssh.com | OpenSSH | | User auth | ssh-ed25519, rsa-sha2-256/512 | RFC 8332 | | Terrapin fix | kex-strict-s-v00@openssh.com | OpenSSH |
Key Exchange
The server generates an ephemeral X25519 keypair per connection. After receiving the client's ephemeral public key, it computes the shared secret via scalar multiplication, builds the exchange hash over the full handshake transcript (V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K), signs it with the Ed25519 host key, and sends KEX_ECDH_REPLY. Session keys are derived from this hash per RFC 4253 §7.2 using chained SHA-256 expansion:
K1 = SHA-256(K || H || X || session_id)
K2 = SHA-256(K || H || K1)
...
Ephemeral private keys and shared secrets are scrubbed with secureZero immediately after use.
Encryption
chacha20-poly1305@openssh.com uses a 64-byte session key split into two halves. The packet length is encrypted separately with the length key (K_1) at counter 0, preventing traffic analysis without decrypting the payload. The payload is encrypted with the payload key (K_2) at counter 1. The Poly1305 tag covers both the encrypted length and the encrypted payload, binding them together. MAC verification uses a timing-safe byte comparison to prevent oracle attacks.
Strict KEX resets sequence numbers to zero on both sides immediately after NEWKEYS, closing the Terrapin sequence injection window (CVE-2023-48795).
Authentication
Authentication is public key only. The library implements the two-phase protocol correctly:
- Probe — client sends
has_signature = falseto test if a key would be accepted. The library responds withPK_OKfor any supported algorithm without invoking the user callback. - Real auth — client sends the full request with a signature. The library verifies the signature cryptographically against the session ID before the user callback is called. A valid signature that the callback rejects is still counted as an attempt.
RSA keys under 2048 bits are rejected with KeyTooSmall before verification proceeds. The connection allows a maximum of 3 attempts before disconnecting with SSH_DISCONNECT_NO_MORE_AUTH_METHODS.
Architecture
The library is organized as eight focused modules. Each owns one layer of the protocol stack.
wire RFC 4251 binary encoding — length-prefixed strings, booleans, mpint, name-list negotiation
cipher chacha20-poly1305@openssh.com seal/open, sequence-derived nonces
packet RFC 4253 §6 framing — sequence numbers, padding, strict-KEX sequence reset
kex Curve25519 ECDH, exchange hash, RFC 4253 §7.2 session key derivation
keys Ed25519 host key gen/load/save (OpenSSH PROTOCOL.key format), SHA-256/MD5 fingerprints
auth Two-phase publickey auth — probe, signature verification, user callback dispatch
channel Session channel open, data, EOF, close, flow-control window management
server TCP accept loop, per-connection state machine, graceful shutdown
Memory
Packet I/O uses stack-allocated scratch buffers throughout. MAX_PACKET is 35,000 bytes, matching the RFC 4253 §6.1 minimum. There are no allocator calls in the hot protocol path. The only heap allocation per connection is stdin buffering, which is bounded by the configurable max_stdin_bytes limit (default 256 MiB) and freed before the connection thread exits.
Concurrency
Each accepted connection is handed off to a detached thread via Thread.spawn. The server tracks active connection count with an atomic counter and enforces a configurable max_connections ceiling, sending SSH_DISCONNECT_TOO_MANY_CONNECTIONS when the limit is reached. Shutdown is coordinated through an atomic flag; accept() is unblocked by a self-connect to the bound port (necessary on macOS where shutdown() on a listening socket does not reliably interrupt accept()).
API
The public surface is minimal by design: a config struct, two callbacks, and a blocking server loop.
const host_key = try ssh.loadHostKey(allocator, "host_ed25519");
var server = try ssh.Server.init(allocator, .{
.host_key = host_key,
.auth_callback = authCb,
.session_callback = sessionCb,
});
try server.listen(io, try std.net.Address.parseIp("0.0.0.0", 2222));
AuthContext is passed to auth_callback after the client's signature has already been cryptographically verified. It exposes:
username()— the username the client presentedpublicKeyBlob()— raw SSH wire-format public keypublicKeyAlgorithm()— the algorithm name stringpublicKeyFingerprint()— lazily-computedSHA256:...fingerprint (cached on first call)
SessionContext is passed to session_callback with stdin fully buffered. It exposes:
command()— the exec command stringstdin()— buffered standard inputwriteStdout(data)— write to the channel's stdout, respecting flow controlwriteStderr(data)— write to the channel's extended data (stderr)setExitStatus(code)— set the exit status sent to the client on close
The session callback runs synchronously on the connection thread. Parallelism, timeouts, and dispatch are left to the caller.
Host Key Management
// Generate and persist a new host key
const key = try ssh.generateHostKey(io);
try ssh.saveHostKey(key, "host_ed25519");
// Load an existing key (OpenSSH PEM format, unencrypted)
const key = try ssh.loadHostKey(allocator, "host_ed25519");
// Compute fingerprints
var fp_buf: [52]u8 = undefined;
const fp = ssh.fingerprintSHA256(public_key_blob, &fp_buf);
// → "SHA256:abc123..."
Keys are stored and loaded in the standard OpenSSH private key format (PROTOCOL.key), so they are interoperable with ssh-keygen and can be examined with standard tooling. Encrypted key files are intentionally not supported.
Build
zig build # static library
zig build test # unit + integration tests
zig build interop # end-to-end tests against a real OpenSSH client
Requires Zig 0.16.0 or later.