My own corner of monopam
monopampam ocaml-ssh
6 folders · 12 files

README.md

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.