ssh #
The SSH protocol (RFCs 4250 to 4254) in pure OCaml, with no I/O in the core.
An SSH implementation that owns its sockets and threads is hard to embed in an
Eio application, a unikernel or a test that wants to replay bytes. This library
implements the SSH wire protocol as state machines that take bytes in and
return bytes out: the message codec, binary packet framing and encryption, key
exchange, client and server authentication, and the channel multiplexer. The
caller owns the transport, so the same core runs over an Eio flow, a
synchronous socket or an in-memory buffer. The ssh-eio package provides the
Eio binding.
A binary packet (RFC 4253, section 6) is a 4-byte packet length, a 1-byte padding length, the payload, 4 to 255 bytes of random padding and a MAC over the sequence number and the packet; once keys are agreed, everything but the MAC is encrypted. Over that one encrypted connection the connection layer (RFC 4254) opens numbered channels, each a shell, a command or a forwarded port with its own flow-control window, and the channel multiplexer routes packets to them.
The library is a fork of awa-ssh. It has no Lwt or MirageOS backend;
its cryptographic primitives come from the nox-crypto libraries and its wire
encoding from wire.
Install #
$ opam install ssh
If opam cannot find the package, it may not yet be released in the public
opam-repository. Add the overlay repository, then install it:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install ssh
Modules #
The modules form four layers: wire primitives, the message AST, the codec and the state machines.
| Module | Role |
|---|---|
Ssh.Core |
wire primitives (RFC 4251 section 5 data types) the messages are built from |
Ssh.Message |
protocol message AST (Message.t and its payload records) |
Ssh.Codec |
wire encode/decode for Message.t (of_string, to_string), built on Ssh.Core |
Ssh.Packet |
binary-packet framing, crypto, and the version banner (RFC 4253 section 6) |
Ssh.Client |
client-side state machine |
Ssh.Server |
server-side state machine (pluggable authenticator) |
Ssh.Channel |
channel multiplexer (RFC 4254) |
Ssh.Kex |
key-exchange (Curve25519, diffie-hellman-group14-sha256, ...) |
Ssh.Cipher, Hmac, Auth, Hostkey, Keys |
algorithm tables and key formats |
Ssh.of_string, Ssh.to_string, Ssh.of_reader and Ssh.to_writer
re-export the corresponding Ssh.Codec functions.
The RFCs the implementation follows are copied in docs/: 4250
(assigned numbers), 4251 (architecture), 4252
(authentication), 4253 (transport), 4254
(connection).
Usage #
The codec layer round-trips an Ssh.Message.t through its wire form:
let m = Ssh.Message.Service_request "ssh-userauth"
let bytes = Ssh.Codec.to_string m
let m' = Ssh.Codec.of_string_exn bytes
let () = assert (m = m')
A client is constructed and driven from a synchronous I/O loop. The caller
owns the network transport and supplies send, recv, a clock for the
rekey timer, and the event handler. Rekeying runs a fresh key exchange inside
the established connection so that no key encrypts too much data or lives too
long: the state machine rekeys after 1 GB in either direction, 2^28 packets or
one hour (RFC 4253 section 9, RFC 4251 section 9.3.2), and the hour needs the
caller's clock because the core does no I/O. On the client, authenticator
checks the server's host key (the key the server signs the key-exchange hash
with) against a pinned key or fingerprint; the user proves itself separately
with priv, the client's own private key:
let run_client
~(send : string -> unit)
~(recv : unit -> Mtime.t * string)
~(handle_event : Ssh.Client.event -> unit)
~authenticator ~user ~(priv : Ssh.Hostkey.priv) =
let client, outbound = Ssh.Client.v ~authenticator ~user (`Pubkey priv) in
List.iter send outbound;
let rec loop client =
let now, buf = recv () in
(* [incoming] reads from any [Bytesrw.Bytes.Reader.t]; an in-memory buffer
and a socket-backed reader are interchangeable to the state machine. *)
match Ssh.Client.incoming client now (Bytesrw.Bytes.Reader.of_string buf) with
| Ok (client, outbound, events) ->
List.iter send outbound;
List.iter handle_event events;
loop client
| Error msg -> Fmt.epr "ssh: %s@." msg
in
loop client
The ssh-eio package wraps a Client over an Eio.Flow.two_way with
Ssh_eio.connect, read_event, write and close. Another backend connects
the same byte queues to its own transport. Ssh_eio.Client.connect bounds a
session as OpenSSH's client does with ssh_config(5)'s ConnectTimeout,
ServerAliveInterval and ServerAliveCountMax, which Ssh.Config reads: a
server silent for one interval is sent a keepalive@openssh.com request, and
the session ends once more than the count went unanswered, so a server that
stopped answering ends a session in a stated time rather than holding it.
Status #
The fork is work in progress. Ssh.Codec encodes and decodes every
Ssh.Message.t through wire codecs, checked by byte-for-byte
round-trip tests and interop traces from paramiko, the widely used Python SSH
implementation: messages paramiko encoded, recorded once and replayed at test
time. The client and server state
machines keep awa-ssh's semantics without its I/O layer.
License #
ISC. See LICENSE.md. The original awa-ssh license is
retained for the inherited code.