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
- Ironclad - cryptography
- usocket - TCP-sockets
- trivial-gray-streams - stream wrappers
- babel - UTF-8 string to octet support
- qlot (more or less)
Test dependencies
- Parachute - test framework
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.
| Category | Algorithm | Tested |
|---|---|---|
| KEX | curve25519-sha256 | Yes |
| KEX | curve25519-sha256@libssh.org | Yes |
| KEX | diffie-hellman-group14-sha256 | Yes |
| Host keys | ssh-ed25519 | Yes |
| Host keys | rsa-sha2-256 | Yes |
| Host keys | rsa-sha2-512 | Yes |
| Publickey auth | ssh-ed25519 | Yes |
| Publickey auth | rsa-sha2-512 | Yes |
| Publickey auth | rsa-sha2-256 | Yes |
| Publickey auth | ssh-rsa | Yes |
| Ciphers | aes128-ctr | Yes |
| Ciphers | aes256-ctr | Yes |
| MACs | hmac-sha2-256 | Yes |
| MACs | hmac-sha2-512 | Yes |
| Compression | none | — |
Supported authentication methods
| Method | Notes |
|---|---|
publickey | Ed25519 and RSA; OpenSSH new-format keys, unencrypted or passphrase-protected |
password | Plaintext inside the encrypted transport |
keyboard-interactive | Callback-based responses for RFC 4256 info requests (e.g. password/OTP prompts) |
none | Probe 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-agentsupport - No server mode
IdentitiesOnlyconfig keyword not supported- Single-threaded;
open-shelldoes 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.
| RFC | Compliant? | Notes |
|---|---|---|
| RFC 4250 | Partial | Constants and algorithm names are defined for the supported subset only. |
| RFC 4251 | Partial | SSH binary data types are implemented, but this is not full architecture compliance. |
| RFC 4252 | Partial | Supports none, password, publickey, restartable partial-success continuation, and callback-driven keyboard-interactive; hostbased and password-change remain unimplemented. |
| RFC 4253 | Partial | Transport, KEXINIT, NEWKEYS, packet framing, and key derivation exist; no rekey and limited algorithms. |
| RFC 4254 | Partial | Basic session channel, exec, shell, subsystem, and flow-control handling exist; port forwarding and server mode do not. |
| RFC 4255 | None, probably too niche as well | DNS SSHFP lookup and validation are not implemented. |
| RFC 4256 | Yes, except | no-echo input handling remains application-defined (§3.3 ¶6). |
| RFC 4344 | Partial | aes128-ctr and aes256-ctr are supported; rekey recommendations are not implemented. |
| RFC 4419 / RFC 8270 | None, but mostly deprecated | Diffie-Hellman group exchange is not implemented. |
| RFC 5647 | None. Should do the OpenSSH one when implementing | AES-GCM is not implemented. |
| RFC 5656 | None, but should be implemented | NIST ECDH, ECDSA, and ECMQV are not implemented; Curve25519 only reuses ECDH packet framing. |
| RFC 6668 | Yes | hmac-sha2-256 and hmac-sha2-512 are advertised and wired into packet MAC handling. |
| RFC 8268 | Partial | diffie-hellman-group14-sha256 is implemented; group15-18 are absent. |
| RFC 8308 | Partial | Not done (yet), §3.2-4: delay-compression, no-flow-control, elevation. |
| RFC 8332 | Partial | RSA-SHA2 host-key verification exists; RSA publickey auth follows server-sig-algs selection. |
| RFC 8709 | Partial | ssh-ed25519 is supported; ssh-ed448 and SSHFP Ed448 handling are absent. |
| RFC 8731 | Partial | curve25519-sha256 and curve25519-sha256@libssh.org are implemented; curve448-sha512 is absent. |
| RFC 8758 | Yes | RC4/arcfour algorithms are not implemented or advertised. |
| RFC 9142 | Partial | Some recommended KEX methods are present, but extension negotiation and several recommended methods are absent. |
| RFC 9941 | None. Should be done in the future | sntrup761x25519-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.