cl-ssh

June 7, 2026 · View on GitHub

A pure Common Lisp SSH 2.0 client.

Status

Core transport, authentication, and session execution work. Use at your own risk.

Only rsa and ed25519 host keys are currently supported.

Developed in SBCL and also tested on ECL.

Dependencies

Test dependencies

Installation

This package is not available in quicklisp. However, it is available in Ultralisp, which makes loading the package as easy as:

(ql:quickload :ssh)

For project-local versioning with Qlot, you could use:

qlot add ultralisp jmeissen-cl-ssh

Usage

with-connection

with-connection is the preferred way to use cl-ssh. It opens the connection, binds the client handle to a variable, runs the body, and always closes the connection on exit - including when an unhandled condition is signaled.

;; Via ~/.ssh/config alias
(ssh:with-connection (client "myserver")
  (multiple-value-bind (stdout stderr code)
      (ssh:run-command client "uname -a")
    (format t "exit ~D~%~A" code stdout)))

All keyword arguments accepted by connect can be passed after the host:

;; Public-key authentication (unencrypted key)
(ssh:with-connection (client "example.com"
                             :username "bob"
                             :identity "~/.ssh/id_ed25519")
  (ssh:run-command client "whoami"))

;; Public-key authentication (passphrase-protected key)
(ssh:with-connection (client "example.com"
                             :username "bob"
                             :identity "~/.ssh/id_ed25519"
                             :passphrase "my passphrase")
  (ssh:run-command client "whoami"))

;; Password authentication
(ssh:with-connection (client "example.com"
                             :username "bob"
                             :password "secret")
  (ssh:run-command client "whoami"))

;; Multi-auth continuation: if the server accepts password auth partially,
;; continue with the next allowed method via the AUTH-PARTIAL-SUCCESS restart.
(handler-bind ((ssh:auth-partial-success
                (lambda (condition)
                  (declare (ignore condition))
                  (invoke-restart 'ssh/auth::continue-authentication
                                  "keyboard-interactive"
                                  (ssh:make-keyboard-interactive-cli-callback)))))
  (ssh:with-connection (client "example.com"
                               :username "bob"
                               :password "secret")
    (ssh:run-command client "whoami")))

;; Keyboard-interactive authentication (RFC 4256)
(ssh:with-connection (client "example.com"
                             :username "bob"
                             :keyboard-interactive-callback
                             (lambda (name instruction language-tag prompts)
                               (declare (ignore name instruction language-tag))
                               ;; Prompt wording differs by server policy; respond per prompt.
                               (mapcar (lambda (prompt)
                                         (declare (ignore prompt))
                                         "secret")
                                       prompts)))
  (ssh:run-command client "whoami"))

;; Keyboard-interactive authentication from stdin.
;; The helper prints the server name, instruction, and each prompt to standard
;; output, then reads one response line per prompt from standard input.
;; If you have a platform-specific reader for hidden input, pass it as
;; :NO-ECHO-READER; otherwise the helper uses plain line input for all prompts.
(ssh:with-connection (client "example.com"
                             :username "bob"
                             :keyboard-interactive-callback
                             (ssh:make-keyboard-interactive-cli-callback))
  (ssh:run-command client "whoami"))

Explicit keyword arguments always override ~/.ssh/config.

Manual lifecycle

When you need explicit control over the connection lifetime, use connect and disconnect directly, but wrap the body in unwind-protect to avoid leaks:

(let ((client (ssh:connect "myserver")))
  (unwind-protect
      (ssh:run-command client "uname -a")
    (ssh:disconnect client)))

Sending commands and receiving output

run-command

Single command execution.

(ssh:with-connection (client "my_host")
  (multiple-value-bind (stdout stderr exit-code)
      (ssh:run-command client "ls -la /tmp")
    (format t "~A" stdout)))

open-shell

Interactive shell.

(ssh:with-connection (client "my_host")
  (ssh:with-open-shell (shell client :pty nil)
    (ssh:shell-write-line shell "cd /tmp")
    (ssh:shell-write-line shell "pwd; printf '\\n__DONE__\\n'")
    (format t "~A" (ssh:shell-read-until shell "__DONE__"))))

Two helper functions are available: ssh:shell-read-line and ssh:shell-read-until. While shell-read-line drains bytes until EOL, shell-read-until drains bytes until marker. However, marker can be NIL, thereby reading/blocking indefinitely. Both can also drain bytes that are currently available on the shell stream without blocking: just drain what is available by passing NIL to :block-p. If the marker passed to shell-read-until is non-nil, then whichever is first will win. Additionally, errors may be suppressed with :error-p NIL, which may modify the second return value in the case of a condition.

Here's an example of using shell-read-until as a non-blocking drain:

(ssh:with-connection (client "my_host")
  (ssh:with-open-shell (shell client)
    (ssh:shell-write-line shell "cd /tmp")
    (ssh:shell-write-line shell "ls -l")
    (sleep 3) ; wait for the commands to successfully execute
    (format t "~A" (ssh:shell-read-until shell nil :block-p nil))))

open-shell itself still returns (values stream channel) for callers that need direct channel access. The helper functions operate on the returned binary stream and hide the string/octet conversion for common interactive-shell use.

Shell commands default to :pty nil, which is for scripted shell interaction like the examples above. A pseudo-terminal is for terminal-oriented programs. With :pty t, servers commonly add prompts, echo typed commands, translate line endings, and emit terminal control sequences. Those bytes are returned as normal shell output, so marker-based reads may still work but the captured text is not machine-clean.

open-subsystem

;; Open an SFTP subsystem stream (raw framing; no SFTP protocol implemented yet)
(multiple-value-bind (stream channel)
    (ssh:open-subsystem client "sftp")
  ...)

Supported algorithms

The Tested column is shorthand: Yes means at least unit-test coverage exists and - means no dedicated test yet. Some have integration tests.

CategoryAlgorithmTested
KEXcurve25519-sha256Yes
KEXcurve25519-sha256@libssh.orgYes
KEXdiffie-hellman-group14-sha256Yes
Host keysssh-ed25519Yes
Host keysrsa-sha2-256Yes
Host keysrsa-sha2-512Yes
Publickey authssh-ed25519Yes
Publickey authrsa-sha2-512Yes
Publickey authrsa-sha2-256Yes
Publickey authssh-rsaYes
Ciphersaes128-ctrYes
Ciphersaes256-ctrYes
MACshmac-sha2-256Yes
MACshmac-sha2-512Yes
Compressionnone

Supported authentication methods

MethodNotes
publickeyEd25519 and RSA; OpenSSH new-format keys, unencrypted or passphrase-protected
passwordPlaintext inside the encrypted transport
keyboard-interactiveCallback-based responses for RFC 4256 info requests (e.g. password/OTP prompts)
noneProbe only

If a server returns partial success, ssh:auth-partial-success is signaled. Handlers may invoke the continue-authentication restart with one of the server-offered methods and the arguments required for that method.

~/.ssh/config support

The following keywords are read and applied:

HostName, Port, User, IdentityFile, StrictHostKeyChecking, UserKnownHostsFile

All other keywords are silently ignored. IdentitiesOnly is not supported: even when set in the config, password authentication may still be attempted if :password is passed to connect.

Known limitations

  • No re-key (subsequent key exchanges after the initial one)
  • No port forwarding
  • No SFTP protocol (subsystem channel can be opened; framing not implemented)
  • No ssh-agent support
  • No server mode
  • IdentitiesOnly config keyword not supported
  • Single-threaded; open-shell does not handle concurrent stdin/stdout

RFC implementation status

This table distinguishes code support from full RFC compliance. "Partial" means the code implements useful pieces of the RFC, but does not implement enough of the RFC to claim full compliance.

RFCCompliant?Notes
RFC 4250PartialConstants and algorithm names are defined for the supported subset only.
RFC 4251PartialSSH binary data types are implemented, but this is not full architecture compliance.
RFC 4252PartialSupports none, password, publickey, restartable partial-success continuation, and callback-driven keyboard-interactive; hostbased and password-change remain unimplemented.
RFC 4253PartialTransport, KEXINIT, NEWKEYS, packet framing, and key derivation exist; no rekey and limited algorithms.
RFC 4254PartialBasic session channel, exec, shell, subsystem, and flow-control handling exist; port forwarding and server mode do not.
RFC 4255None, probably too niche as wellDNS SSHFP lookup and validation are not implemented.
RFC 4256Yes, exceptno-echo input handling remains application-defined (§3.3 ¶6).
RFC 4344Partialaes128-ctr and aes256-ctr are supported; rekey recommendations are not implemented.
RFC 4419 / RFC 8270None, but mostly deprecatedDiffie-Hellman group exchange is not implemented.
RFC 5647None. Should do the OpenSSH one when implementingAES-GCM is not implemented.
RFC 5656None, but should be implementedNIST ECDH, ECDSA, and ECMQV are not implemented; Curve25519 only reuses ECDH packet framing.
RFC 6668Yeshmac-sha2-256 and hmac-sha2-512 are advertised and wired into packet MAC handling.
RFC 8268Partialdiffie-hellman-group14-sha256 is implemented; group15-18 are absent.
RFC 8308PartialNot done (yet), §3.2-4: delay-compression, no-flow-control, elevation.
RFC 8332PartialRSA-SHA2 host-key verification exists; RSA publickey auth follows server-sig-algs selection.
RFC 8709Partialssh-ed25519 is supported; ssh-ed448 and SSHFP Ed448 handling are absent.
RFC 8731Partialcurve25519-sha256 and curve25519-sha256@libssh.org are implemented; curve448-sha512 is absent.
RFC 8758YesRC4/arcfour algorithms are not implemented or advertised.
RFC 9142PartialSome recommended KEX methods are present, but extension negotiation and several recommended methods are absent.
RFC 9941None. Should be done in the futuresntrup761x25519-sha512 and the OpenSSH alias are not implemented.

Quite some SSH RFCs are omitted. Some are deprecated, some too niche to even mention, or they do not seem relevant at the time of writing. Please open an issue upon disagreement.

Contributing

Installing

git clone https://github.com/jmeissen/cl-ssh
cd cl-ssh
qlot install

Running tests

sbcl --disable-debugger --load .qlot/setup.lisp --eval '(asdf:test-system :ssh)' --quit

Integration tests

Live integration tests run against Docker Compose OpenSSH services on 127.0.0.1:2222-2225 (default, RSA variants, and dedicated keyboard-interactive).

./scripts/run-integration-tests.sh

Set SSH_TEST_REBUILD=1 if you changed the Docker image or want a fresh rebuild.

If you want to drive them manually, set SSH_TEST_HOST, SSH_TEST_PORT, SSH_TEST_RSA_SHA2_256_PORT, SSH_TEST_SSH_RSA_PORT, SSH_TEST_KBDINT_PORT, SSH_TEST_USER, SSH_TEST_PASSWORD, and SSH_TEST_KNOWN_HOSTS, then run asdf:test-system :ssh/integration-tests.