10 unstable releases (3 breaking)

0.4.2 May 28, 2024
0.4.1 May 19, 2024
0.3.2 May 5, 2024
0.3.1 Apr 30, 2024
0.1.1 Feb 29, 2024

#165 in Embedded development

36 downloads per month

MIT license

120KB
2.5K SLoC

zssh

Documentation Crates.io

This crate contains a minimal SSH server implementation written in safe Rust targeted towards embedded devices. The distinguishing feature of this library is that it is simultaneously no_std, no_alloc and async and implements a useful subset of the SSH specification with very low memory usage. As of now I am not aware of any other public Rust implementation with these characteristics.

  • #![no_std]
  • #![forbid(unsafe_code)]
  • Zero heap allocations
  • Uses asynchronous I/O
  • Compiles on stable

This library is still in early development. Features may be added and the API may change in breaking ways! Pull requests and issues are welcome.

Protocol Features

Feature Status Details
Key Exchange ✔️ curve25519-sha256
Host Key ✔️ ssh-ed25519
Encryption ✔️ chacha20-poly1305@openssh.com
Integrity ✔️ (implicit)
Compression none
Authentication ✔️ none, publickey
Public-Key Auth ✔️ ssh-ed25519
Client Env-vars Currently ignored.
Exec Requests ✔️ You must parse the command.
Shell Requests ✔️ You must implement the shell.
PTY, X11, TCP Will not be implemented.
Multiplexing ✔️ Open channels are serialized.
Rekeying ✔️ As initiated by the client.

Compatibility

The goal of the library is to achieve SSH connectivity with a minimally low memory and code footprint, thus it implements a small yet modern suite of cryptographic algorithms which will be understood by most clients from the past decade or so.

Usage

The example in examples/demo.rs will set up a local SSH server running on localhost on port 2222. You can connect to it using either the zssh username (with the private key in examples/zssh.priv as an SSH identity) or the guest username (with no authentication). The example supports three commands, demonstrating different aspects of the API:

  1. sha256sum computes the SHA-256 digest of stdin and returns it on stdout;
  2. echo sends stdin back to stdout unchanged;
  3. sum prints out the sum of all numbers passed on the command-line;

The example uses Tokio only for the purposes of running on any operating system.

Design Notes

The API has been guided by certain design constraints, some of which are described in this section.

Reading

Because of the no-allocation requirement, it is not possible for a channel to have a non-zero recv window without forcing you to read from the channel. This is because receiving a data message prevents making any further progress until that message is processed, or its payload lost.

This constraint implies that you must specify an exact read length for all channel reads unless you are willing to never read again from the channel after you write to it. In the API, this manifests as Channel::reader taking an optional size, with the following semantics:

  • if a size is provided, the reader object must read up to that much, and the channel may then be read or written afterwards;
  • otherwise, it will read up to EOF, but you can never call Channel::reader again on the channel once you drop the reader.

Also note the possible performance implications due to how the recv window is advertised to the client. If a size is provided to the reader, we will advertise that size as our recv window, so the client may not submit more than that many bytes at a time. If no size is provided to the reader, we will maintain a recv window of 2^32 - 1 bytes, the maximum allowed by the protocol, allowing the client to send unlimited amounts of data.

Writing

Because of the no-allocation requirement, it is not possible for the transport to buffer writes internally, as it would have no way to service non-channel-related messages (such as periodic rekeying requests from the client) without trashing the packet buffer. Therefore, in the API, any individual call to Channel::write_all_stdout or Channel::write_all_stderr translates to at least one SSH protocol-level message.

The API offers a zero-copy feature in the form of Channel::writer which allows the caller to directly write data into the packet buffer for sending. This both avoids copies and allows the caller to buffer as much data as it can to fit into a single SSH protocol-level message as desired.

However, please note that:

  • the Writer::write_all method consumes the writer, this is by design as once the data is ready to be sent, it is encrypted in-place by the transport; this is to avoid an API footgun and has no performance implications since Channel::writer does no work;
  • as a corollary to the previous point, callers must assume that Writer::buffer contains garbage, it is not zeroed out by the crate!

Performance

The library depends on the async I/O traits from embedded-io-async and is written in an essentially zero-copy fashion. It is expected the main performance bottleneck will simply be the transport-layer encryption overhead. In terms of memory usage, the transport borrows an externally-provided byte slice which it will use as a packet buffer, while its internal state machine requires around 1-2kB of memory (largely consisting of key material and other cryptographic state) with low stack usage.

While the SSH specification mandates a minimum packet buffer size of 32768 bytes, in practice most clients will work out of the box with sizes as low as 4kB, and even smaller packet buffers can be made to work with suitably configured clients; in general, the limiting factor is the size of the client's initial KEXINIT message.

This makes zssh usable on the vast majority of embedded targets where running an SSH server would make sense to begin with.

Multiplexing

The library supports the ability to handle multiple concurrent channels on the same connection. It does this by only processing one channel at a time and delaying responding to further channel open requests. This is done so that clients can use multiplexing for better end-user experience even if the server only supports a single TCP connection. Currently, the transport is hardcoded such that up to four channels can be pending while one is active. Additional channel open requests beyond this limit will be rejected by the transport.

Dependencies

~2.4–3.5MB
~64K SLoC