cl-ssh
July 14, 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
Using with-connection is preferred. Connecting to existing hosts from
~/.ssh/config is supported to a certain extent (see SSH Config Support for details).
;; 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~%~A" code stdout stderr)))
All keyword arguments accepted by ssh:connect can be passed after the host:
Public-key authentication with unencrypted key
(ssh:with-connection (client "example.com"
:username "bob"
:identity "~/.ssh/id_ed25519")
(ssh:run-command client "whoami"))
Public-key authentication with passphrase-protected key
(ssh:with-connection (client "example.com"
:username "bob"
:identity "~/.ssh/id_ed25519"
:passphrase "my passphrase")
(ssh:run-command client "whoami"))
Or with simple password authentication
(ssh:with-connection (client "example.com"
:username "bob"
:password "secret")
(ssh:run-command client "whoami"))
Multi-auth continuation is also supported. The server may partially accept a
password and continue with the next allowed method through the
ssh: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
(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"))
Finally, keyboard-interactive authentication from stdin for which you may use
the helper which 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"))
Note
Explicit keyword arguments always override ~/.ssh/config.
Manual lifecycle
When you need explicit control over the connection lifetime, use ssh:connect
and ssh: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
Single command execution with ssh:run-command.
(ssh:with-connection (client "my_host")
(multiple-value-bind (stdout stderr exit-code)
(ssh:run-command client "ls -la /tmp")
(declare (ignore stderr exit-code))
(format t "~A" stdout)))
Interactive shells may be opened with ssh:with-open-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 and only drain what is
available by passing NIL to :block-p - 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))))
ssh: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. The channel can be closed with the usual close.
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 |
| Ciphers | aes128-gcm@openssh.com | Yes |
| Ciphers | aes256-gcm@openssh.com | Yes |
| Ciphers | AEAD_AES_128_GCM | Yes |
| Ciphers | AEAD_AES_256_GCM | 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 ssh/auth::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, RekeyLimit
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 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, key derivation, and rekey exist; 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, aes256-ctr and rekey limits are implemented; aes192-ctr and the other CTR ciphers are absent. |
| RFC 4419 / RFC 8270 | None, but mostly deprecated | Diffie-Hellman group exchange is not implemented. |
| RFC 5647 | Yes | The common OpenSSH names are also supported. |
| 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
qlot exec sbcl --disable-debugger --eval '(asdf:test-system :ssh)' --quit
ECL
qlot exec ecl --eval '(ql:quickload :ssh)' --eval '(asdf:test-system :ssh)' --nodebug -q --eval '(uiop:quit)'
Note
Because of non-optimized Ironclad bcrypt-pbkdf on some implementations (including ECL) we only test passphrased client keys on SBCL.
Integration tests
Live integration tests run against Docker Compose OpenSSH services on
127.0.0.1:2222-2228.
./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. Banner coverage is enabled by
SSH_TEST_BANNER_PORT with SSH_TEST_BANNER_TEXT.