A complete pure-Zig SSH-2.0 client and server: transport (RFC 4253), user
authentication (RFC 4252) and the connection protocol (RFC 4254) — i.e.
everything needed to run a remote command over SSH, both as the client and
as the server. No @panic stubs remain in this module.
Status: parts 1-3 IMPLEMENTED (client + server).
- Part 1 — transport (
transport.zigclient,server.zigserver): version exchange, KEXINIT negotiation,mlkem768x25519-sha256/curve25519-sha256(RFC 8731) /diffie-hellman-group14-sha256/-group16-sha512key exchange, the RFC 4253 §6 Binary Packet Protocol,chacha20-poly1305@openssh.com/aes256-ctr+hmac-sha2-256/aes{128,256}-gcm@openssh.comciphers, and host-key verification (client) / signing (server) for ssh-ed25519, rsa-sha2-256/512 (via thersamodule) and ecdsa-sha2-nistp256. Once a handshake completes,Transport.negotiatedreports the negotiated KEX/host-key/cipher/MAC wire names (both roles) — diagnostics parity withssh -v's negotiation banner. RFC 8308 extension negotiation is here too, both roles:ext-info-c/ext-info-sandSSH_MSG_EXT_INFOcarryingserver-sig-algs. - Part 2 — userauth (
userauth.zig): thepublickeymethod (RFC 4252 §7) including the two-phase query →SSH_MSG_USERAUTH_PK_OK→ signed-request flow, and thepasswordmethod (§8), plus_FAILURE/_SUCCESS/_BANNER— both roles. The signature is bound to the transport's session id (see SPEC.md §2: that binding is the security property). The signature algorithm comes from the server'sserver-sig-algswhen it sent one, which is what makes a realsshwilling to offer an RSA user key at all: anssh-rsablob names no hash, and a client that is not told will not guess. A server's_BANNERreaches the caller throughuserauth.BannerHandler. - Part 3 — connection protocol (
connection.zig):"session"channels with real RFC 4254 §5.2 window/flow control,CHANNEL_DATA/_EXTENDED_DATA(stderr) /_EOF/_CLOSE, and the"exec"/"subsystem"requests plus §6.10exit-status— both roles.
Validated with live interop against real OpenSSH 10.2p1 in both
directions, including authentication and command execution: our client
authenticates to a spawned real sshd with a public key and runs a command
(asserting stdout, stderr and exit status), and a real ssh client
authenticates to our server and runs one. Green in Debug and ReleaseFast,
no skips.
- Model after: RFC 4253 (Transport) / RFC 4251 (Architecture — wire types)
/ RFC 4252 (Authentication) / RFC 4254 (Connection) / RFC 8731
(curve25519-sha256) / RFC 8332, 8709, 5656 (host+user key algorithms) /
RFC 8308 (extension negotiation,
server-sig-algs). Design reference: ringtailsoftware/misshod (MIT) for architecture shape only — no source copied. - Platform: linux — the transport's
fillRandomis a rawgetrandom(2)loop on the Binary-Packet-Protocol write path, so a non-Linux target fails to compile, it does not silently degrade. Role: both (client + server). Concurrency: single_owner — oneTransportinstance owns one connection's sequence-number/cipher state; no shared/global state. - Deps:
rsa(this repo's own module) — forrsa-sha2-256/rsa-sha2-512(RFC 8332) signature verify/sign, as host keys and as user keys. - Crypto: Zig
std.crypto— X25519 + ML-KEM-768 (KEX), Ed25519/P-256 (signatures), ChaCha20-Poly1305 / AES-CTR / AES-GCM (ciphers), SHA-2 / HMAC-SHA2-256.
Clean-room implementation from RFC 4253 (SSH Transport Layer Protocol),
RFC 4251 (SSH Protocol Architecture — the mpint/string/name-list wire types),
RFC 4252 (Authentication Protocol), RFC 4254 (Connection Protocol) and RFC 8731
(curve25519-sha256 key exchange). The chacha20-poly1305@openssh.com cipher
framing and (when implemented) aes-gcm-family framing follow the OpenSSH
PROTOCOL/PROTOCOL.chacha20poly1305 notes and RFC 5647 — public
documentation of OpenSSH's own protocol extensions, not OpenSSH source.
Design reference: ringtailsoftware/misshod (MIT) — architecture SHAPE only,
no source copied. Crypto primitives come from Zig std.crypto (X25519,
Ed25519, ChaCha20-Poly1305, P-256, SHA-2, HMAC) plus this repo's own rsa
module for rsa-sha2 (RFC 8332) host-key verification. No GPL/LGPL source
consulted or copied anywhere in this module.
const ssh = @import("ssh");
// ── client: connect → authenticate → run a command ────────────────────────
// The host-key policy is yours: a struct of your own reached through `ctx`.
// `key.key_blob` does NOT outlive the call — copy it if you keep it.
const KnownHosts = struct {
path: []const u8,
fn verify(ctx: *anyopaque, key: ssh.transport.HostKeyInfo) ssh.transport.HostKeyVerdict {
const self: *KnownHosts = @ptrCast(@alignCast(ctx));
return switch (lookUp(self.path, key.host, key.port, key.key_type, key.key_blob)) {
.match => .accept,
.absent => .{ .reject = .unknown_host }, // or prompt, then .accept
.different => .{ .reject = .key_mismatch },
};
}
};
var policy: KnownHosts = .{ .path = "~/.ssh/known_hosts" };
var failure: ssh.transport.HostKeyFailure = undefined;
var t = ssh.transport.connect(&reader, &writer, gpa, .{
.verifier = .{ .ctx = &policy, .verifyFn = KnownHosts.verify },
.host = "router.example.net", // what the verifier looks up
.port = 22,
.failure = &failure, // optional: WHY it was refused
}) catch |err| switch (err) {
error.HostKeyVerificationFailed, error.UnsupportedAlgorithm => {
// `failure` distinguishes our own refusal (`.policy = .key_mismatch`,
// `.unknown_host`, `.revoked`, `.declined`) from the module's
// (`.algorithm_mismatch`, `.bad_signature`, `.unsupported_algorithm`).
return report(failure);
},
else => return err,
};
// RFC 4252 publickey (requests the ssh-userauth service, then authenticates;
// the signature is bound to t.session_id).
const key = try ssh.userauth.AuthKey.fromOpenSSH(id_ed25519_text, null);
try ssh.authenticate(&t, gpa, "alice", key);
// ...or step-by-step / other methods — and this is the form that can show the
// server's RFC 4252 §5.4 banner, which `authenticate` above has nowhere to put:
// try t.requestService("ssh-userauth", &buf);
// try ssh.userauth.authenticatePublickey(&t, gpa, "alice", key, .{ .banner = my_banner });
// try ssh.userauth.authenticatePassword(&t, gpa, "alice", secret, .{});
// RFC 4254 exec: one call, stdout + stderr + exit status.
var r = try ssh.exec(&t, gpa, "uname -a", .{});
defer r.deinit(gpa);
std.debug.print("{s} (exit {?d})\n", .{ r.stdout, r.exit_status });
// ...or drive the channel yourself (streaming; this is what a NETCONF /
// RFC 6242 caller wants):
var s = try ssh.openSession(&t, gpa, .{});
defer s.deinit();
try s.subsystem("netconf");
try s.writeData(hello_xml);
while (...) {
_ = try s.pumpOnce(); // fills s.stdout / s.stderr, keeps the window open
}
try s.close();
// ── server: accept → authenticate → serve one session channel ─────────────
fn authorizedKey(ctx: *anyopaque, user: []const u8, algorithm: []const u8, key_blob: []const u8) bool {
// caller's own authorized_keys policy, over caller's own state.
const self: *Server = @ptrCast(@alignCast(ctx));
_ = algorithm;
return std.mem.eql(u8, user, "alice") and std.mem.eql(u8, key_blob, self.alice_blob);
}
fn runCommand(
ctx: *anyopaque,
a: std.mem.Allocator,
user: []const u8,
command: []const u8,
stdin: []const u8,
stdout: *std.ArrayList(u8),
stderr: *std.ArrayList(u8),
) ssh.connection.CommandError!u32 {
_ = ctx;
_ = stdin;
try stdout.print(a, "hello {s}, you asked for {s}\n", .{ user, command });
_ = stderr;
return 0; // exit status
}
const host_key = try ssh.server.HostKey.fromOpenSSH(openssh_key_v1_text, null);
var st = try ssh.server.accept(&reader, &writer, gpa, .{
.host_keys = &.{host_key},
// RFC 8308 `server-sig-algs`, sent to any client that offered `ext-info-c`.
// Defaults to every algorithm `serveUserauth` can verify; narrow it (never
// widen it) if `authorized_key` below refuses some of them — advertising a
// name that hook then rejects leaves a client with nothing to fall back to.
.server_sig_algs = &.{ "ssh-ed25519", "rsa-sha2-512" },
});
var why: ssh.userauth.AuthFailure = undefined; // optional: what to log
const auth = try ssh.userauth.serveUserauth(&st, gpa, .{
.authorized_key = .{ .ctx = &server_state, .checkFn = authorizedKey },
.failure = &why,
});
try ssh.connection.serveSession(&st, gpa, .{
.user = auth.user(),
.exec = .{ .ctx = &server_state, .runFn = runCommand },
});Top-level shortcuts: ssh.authenticate (client publickey auth),
ssh.openSession (= ssh.connection.Session.open), ssh.exec
(= ssh.connection.exec). Namespaces: ssh.transport, ssh.server,
ssh.userauth, ssh.connection, ssh.messages.
See src/transport.zig for the full client transport API (algorithm
name-list constants, KexInit, exchangeVersions, Packet/CipherState/
readPacket/writePacket, HostKeyVerifier/HostKeyInfo/HostKeyVerdict/
HostKeyPolicy/HostKeyFailure, NegotiatedAlgorithms, Transport/connect),
src/server.zig for the server transport API (HostKey, ServerConfig,
serverHandshake/accept), src/userauth.zig and src/connection.zig for
parts 2 and 3, and SPEC.md for the design/threat notes and what is
deferred.
keyboard-interactive and hostbased authentication, the password-change
sub-protocol, agent forwarding, OpenSSH certificate key types; pty-req /
shell / env / signal / exit-signal / window-change channel requests,
X11 and TCP/IP port forwarding; more than one channel per connection;
rekeying; compression. Requests for any of them are answered
SSH_MSG_CHANNEL_FAILURE / SSH_MSG_CHANNEL_OPEN_FAILURE rather than
mishandled. See SPEC.md → Backlog.
zig build test-ssh — all passing, Debug and ReleaseFast, no skips with
OpenSSH installed:
- wire-codec round-trips and
Cursorbounds tests (oversize/off-by-one/ truncated lengths are typed errors, never panics); - transport KAT/self-consistency (KEXINIT, KDF, every cipher, tamper detection, RFC 3526 primes, degenerate DH values);
HostKey.fromOpenSSHfixtures checked againstssh-keygen's.pubblob;- userauth unit tests (
signedBlobfield order, session-id sensitivity, the algorithm↔key-blob-type pairing, crafted-wire oversize rejection); - full-stack loopback self-interop — our client ↔ our server over a real socket: KEX → publickey userauth → channel open → exec → stdout/stderr/ exit-status → close, incl. 100 KB of output through a 16 KB window so the flow control really blocks;
- reject-teeth with positive controls — wrong session id, unauthorized key, channel-open before auth, request after close, window overrun, unknown channel type;
- live interop against real OpenSSH 10.2p1 both directions, incl. publickey
auth and
exec(skipped ifsshd/ssh/ssh-keygenare absent).