zig-ssh

@june

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.

Zig

v0.1.10 · 38 saves · 3,655 lines · updated 4 weeks ago · trusted

38 saves
24 files ⇓ bundle
38 saves
60f492f38052fix: Replace missing SPEC reference (v0.1.10) - Point README and server docs at OVERVIEW.md instead of missing SPEC.md - Include OVERVIEW.md in package paths - Bump package version to 0.1.10dev4 weeks ago
91805a9c24a6fix: Derive server banner from package version (v0.1.9) - Read build.zig.zon version in build.zig and expose it as build_options.version - Use transport.SERVER_VERSION as the default server banner - Remove stale versioned banner literals from KEX fixturesdev4 weeks ago
299efa3b822efix: Reject trailing bytes in fixed parsers (v0.1.8) - Add MalformedMessage for strict wire parsing - Reject trailing bytes in KEXINIT, ECDH_INIT, service request, and userauth - Preserve OpenSSH interoperabilitydev4 weeks ago
d95a7f09f05ffix: Enforce channel data max packet size (v0.1.7) - Reject inbound CHANNEL_DATA larger than the server channel max packet - Add PacketTooLarge as a session error - Check size before buffering stdin or updating windowsdev4 weeks ago
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 | [email protected] | OpenSSH | | User auth | ssh-ed25519, rsa-sha2-256/512 | RFC 8332 | | Terrapin fix | [email protected] | 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

[email protected] 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:

  1. Probe — client sends has_signature = false to test if a key would be accepted. The library responds with PK_OK for any supported algorithm without invoking the user callback.
  2. 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     [email protected] 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 presented
  • publicKeyBlob() — raw SSH wire-format public key
  • publicKeyAlgorithm() — the algorithm name string
  • publicKeyFingerprint() — lazily-computed SHA256:... fingerprint (cached on first call)

SessionContext is passed to session_callback with stdin fully buffered. It exposes:

  • command() — the exec command string
  • stdin() — buffered standard input
  • writeStdout(data) — write to the channel's stdout, respecting flow control
  • writeStderr(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.