From 7058e7826301330afa5229df856b06a982753c8d Mon Sep 17 00:00:00 2001 From: Kristian Larsson Date: Thu, 27 Aug 2026 11:54:27 +0200 Subject: [PATCH] Add typed SSH transport configuration SSH negotiation relied entirely on libssh defaults, while key handling still exposed filesystem paths. Instead, expose exhaustive typed algorithm catalogs and a shared TransportConfig for client and server negotiation, compression, rekeying, and RSA limits. Accept private keys, host keys, certificates, and known_hosts data as in-memory values, including ordered multiple-key authentication. Keep connection and credential arguments directly on Client and Server, and verify the catalogs against the bundled libssh tables. --- README.md | 116 +++++++- src/example_client.act | 13 +- src/interop_client.act | 9 +- src/lib.act | 521 ++++++++++++++++++++++++++++++++++- src/lib.ext.c | 536 +++++++++++++++++++++++++++++++++---- src/pipe.act | 30 ++- src/test_interop.act | 5 +- src/test_ssh.act | 149 +++++++++-- src/test_ssh_catalog.act | 47 ++++ src/test_ssh_catalog.ext.c | 20 ++ 10 files changed, 1326 insertions(+), 120 deletions(-) create mode 100644 src/test_ssh_catalog.act create mode 100644 src/test_ssh_catalog.ext.c diff --git a/README.md b/README.md index a651b64..e827ce8 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,21 @@ Acton server, to OpenSSH `sshd`, or be driven by the OpenSSH `ssh` client. ## Features - **Client**: password and public-key authentication, host-key verification - (known_hosts and/or an `on_hostkey` callback), connect/auth timeouts, - keepalive. + (in-memory known_hosts data and/or an `on_hostkey` callback), multiple + in-memory private keys, connect/auth timeouts, and keepalive. - **Channels**: `exec`, `shell` (with optional PTY), and `subsystem` requests; stdout/stderr streaming; EOF, exit status, and close with a defined callback ordering. `RunCommand` is a convenience wrapper that buffers output. - **Server**: password and public-key auth callbacks, `exec`/`subsystem` - dispatch, per-channel data streaming, ephemeral-port binding, in-memory or - file-based host keys, and admission limits (max sessions / channels). + dispatch, per-channel data streaming, ephemeral-port binding, multiple + in-memory host keys, and admission limits (max sessions / channels). +- **Transport configuration**: exhaustive typed catalogs for ciphers, MACs, key + exchange, host-key and public-key algorithms, plus zlib compression, rekey + limits, and minimum RSA key size. The same `TransportConfig` works for + clients and servers. - **Compression**: `zlib@openssh.com` and `zlib` are compiled in and offered - during key exchange, so a peer that asks for compression gets it. libssh - proposes `none` first, so it is never used unless the peer prefers it. + during key exchange. libssh proposes `none` first, so compression is only + used when a peer or `TransportConfig` prefers it. - **Hardened**: bounded teardown (a stalled peer can't wedge a close), per-channel write-buffer limits, an accept loop that survives per-connection failures, and no use-after-free under load (validated with @@ -42,9 +46,9 @@ the same headers-here/objects-there split used for mbedtls. The zlib version is pinned in one place (acton-zlib), and an executable that also uses the Acton `zlib` package links a single copy. -libssh and its crypto backend (mbedtls) run on the C library heap with their -own ownership; only libuv and Acton objects live on the GC heap. See the -header comment in [`src/ssh.ext.c`](src/ssh.ext.c) for the full memory model. +libssh and its crypto backend (mbedtls) use the C library heap with their own +ownership; only libuv and Acton objects live on the GC heap. See the header +comment in [`src/lib.ext.c`](src/lib.ext.c) for the full memory model. ## Building @@ -140,12 +144,92 @@ and [`src/example_server.act`](src/example_server.act): ./out/bin/example_client 127.0.0.1 demo "hello" 2222 demo ``` +## Transport configuration + +`TransportConfig` contains only settings shared by clients and servers. +Connection details and credentials remain direct arguments to `Client` and +`Server`. + +```python +transport = ssh.TransportConfig( + ciphers=[ssh.cipher.chacha20_poly1305, ssh.cipher.aes256_gcm], + macs=[ssh.mac.hmac_sha2_256_etm, ssh.mac.hmac_sha2_512_etm], + key_exchanges=[ssh.kex.curve25519_sha256], + host_key_algorithms=[ssh.hostkey.ssh_ed25519, + ssh.hostkey.rsa_sha2_512], + public_key_algorithms=[ssh.pubkey.ssh_ed25519, + ssh.pubkey.rsa_sha2_512], + compression_algorithms=[ssh.compression.zlib_openssh, + ssh.compression.none], + compression_level=6, + rekey_after_bytes=1073741824, + rekey_after_seconds=3600, + minimum_rsa_bits=3072, +) + +client = ssh.Client(connect_cap, host, user, on_connect, on_close, + password=password, port=u16(22), transport=transport) + +server = ssh.Server(listen_cap, host, u16(22), on_listen, on_close, + on_session, on_auth, on_channel_open, + transport=transport) +``` + +Each algorithm list is the exact ordered preference list used in both +directions. A category that is not supplied defaults to the library default +(`ssh.cipher.default` and friends); an empty list is rejected. `rekey_after_bytes` +left unset rekeys at the negotiated cipher's RFC 4344 volume limit, and a set +value can only lower that limit; `rekey_after_seconds` left unset disables +time-based rekeying. The catalog's `default` and `supported` members are +ordinary typed lists, so a default-derived configuration uses normal Acton +list operations: + +```python +ciphers = list(ssh.cipher.default) +ciphers.pop() # drop the least-preferred default +transport = ssh.TransportConfig(ciphers=ciphers) +``` + +The configuration snapshots supplied lists when it is created. The catalogs +exhaust the pinned libssh build, and `TransportConfig` rejects constructed +algorithm names outside it. Tests verify the catalog contents against +libssh's compiled method tables. + +## In-memory keys and known hosts + +The library does not accept key or known_hosts paths. Use Acton's normal file, +secret-store, or network APIs to obtain bytes first, then construct the SSH +values: + +```python +private_key = ssh.PrivateKey(private_key_bytes, + passphrase=passphrase, + certificate=certificate_bytes) + +client = ssh.Client(connect_cap, host, user, on_connect, on_close, + password=password, + private_keys=[private_key], + known_hosts=known_hosts_bytes, + port=u16(22), transport=transport) + +server = ssh.Server(listen_cap, host, u16(22), on_listen, on_close, + on_session, on_auth, on_channel_open, + host_keys=[private_key], transport=transport) +``` + +Private key material may be PEM or OpenSSH format. `certificate`, when used, +is an OpenSSH public certificate line. `known_hosts` is the byte content in +OpenSSH known_hosts format. Multiple client keys are tried in order; multiple +server host keys allow negotiation across key types. If `host_keys` is `None`, +the server generates an ephemeral in-memory key using `host_key_type` and +`host_key_bits`. + ## API overview - **`Client(cap, host, username, on_connect, on_close, on_hostkey?, ...)`** — `accept_hostkey()`, `reject_hostkey(reason)`, `close()`. Auth via `password` - and/or `private_key_file` (+ `private_key_passphrase`). Tunables: - `port`, `known_hosts`, `connect_timeout`, `auth_timeout`, + and/or `private_keys: list[PrivateKey]`. Tunables: `port`, in-memory + `known_hosts`, `transport`, `connect_timeout`, `auth_timeout`, `keepalive_interval`, `keepalive_enabled`, `close_timeout`, `max_write_buffer`. - **`Channel(client, on_open, on_stdout, on_stderr, on_exit, on_close)`** — @@ -157,8 +241,14 @@ and [`src/example_server.act`](src/example_server.act): - **`Server(cap, host, port, on_listen, on_close, on_session, on_auth, on_channel_open, on_exec?, on_subsystem?, on_session_close?, ...)`** — `close()`, `bound_port()` (use `port=0` for an ephemeral port). Tunables - include `host_key_path`/`host_key_type`/`host_key_bits`, the timeouts above, - and `max_sessions` / `max_channels_per_session` / `max_write_buffer`. + include in-memory `host_keys`, generated-key fallback via + `host_key_type`/`host_key_bits`, `transport`, the timeouts above, and + `max_sessions` / `max_channels_per_session` / `max_write_buffer`. +- **`TransportConfig`** — shared exact algorithm preferences, compression, + rekey thresholds, and minimum RSA size. Typed catalogs are exposed as + `cipher`, `mac`, `kex`, `hostkey`, `pubkey`, and `compression`. +- **`PrivateKey`** — in-memory private-key bytes, optional passphrase, and an + optional in-memory OpenSSH certificate. - **`ServerSession`** — `accept_auth()`, `reject_auth(reason)`, `accept_channel(ServerChannel)`, `reject_channel(reason)`, `close()`. - **`ServerChannel(session, on_data, on_stderr, on_close)`** — diff --git a/src/example_client.act b/src/example_client.act index e9175fb..707d621 100644 --- a/src/example_client.act +++ b/src/example_client.act @@ -3,8 +3,8 @@ Build: acton build Run: ./out/bin/example_client HOST USER CMD [PORT] [PASSWORD] - - With PASSWORD, authenticates by password; otherwise by the private key - in KEYFILE (default ~/.ssh/id_ed25519). + - With PASSWORD, authenticates by password; otherwise the application reads + ~/.ssh/id_ed25519 and passes its bytes to ssh.PrivateKey. - Host key checking here is permissive (accepts any key) so the example works against an unknown server. A real client must verify the host key against known_hosts or a pinned fingerprint. @@ -14,6 +14,7 @@ Examples: ./out/bin/example_client example.com alice uptime """ +import file import net import lib as ssh @@ -39,6 +40,12 @@ actor main(env): if home is not None: key_file = home + "/.ssh/id_ed25519" + var private_keys: ?list[ssh.PrivateKey] = None + if key_file is not None: + reader = file.ReadFile(file.ReadFileCap(file.FileCap(env.cap)), key_file) + private_keys = [ssh.PrivateKey(reader.read())] + reader.close() + var client: ?ssh.Client = None def on_hostkey(c: ssh.Client, state: str, info: ssh.HostKeyInfo): @@ -80,6 +87,6 @@ actor main(env): on_close, on_hostkey, password=password, - private_key_file=key_file, + private_keys=private_keys, port=port, ) diff --git a/src/interop_client.act b/src/interop_client.act index 4aefb08..9c5eecd 100644 --- a/src/interop_client.act +++ b/src/interop_client.act @@ -7,6 +7,7 @@ fallback PASSWORD), runs CMD, prints its stdout between OUT-BEGIN/OUT-END markers and exits with the remote exit status. """ +import file import net import lib as ssh @@ -27,6 +28,12 @@ actor main(env): if len(env.argv) > 6: password = env.argv[6] + var private_keys: ?list[ssh.PrivateKey] = None + if keyfile is not None: + reader = file.ReadFile(file.ReadFileCap(file.FileCap(env.cap)), keyfile) + private_keys = [ssh.PrivateKey(reader.read())] + reader.close() + var client: ?ssh.Client = None def on_hostkey(c: ssh.Client, state: str, info: ssh.HostKeyInfo): @@ -69,7 +76,7 @@ actor main(env): on_close, on_hostkey, password=password, - private_key_file=keyfile, + private_keys=private_keys, port=port, connect_timeout=10.0, auth_timeout=10.0, diff --git a/src/lib.act b/src/lib.act index 6d89dc7..3a7a727 100644 --- a/src/lib.act +++ b/src/lib.act @@ -35,7 +35,7 @@ Server authentication: so the handler only decides authorization of the (user, key) pair. Authentication (client): - - If private_key_file is set, public key authentication is attempted first. + - If private_keys is set, public key authentication is attempted first. - If password is set, password authentication is attempted (after pubkey, if both are given). - If neither is given, the connection fails. @@ -61,7 +61,7 @@ Notes: channel close, half-open connection) escalates to a forced close after close_timeout seconds. - Timeouts and keepalive are configurable; server host keys are generated - in-memory unless host_key (PEM/OpenSSH private key material) is provided. + in-memory unless host_keys are provided. - Server admission limits are configurable; values <= 0 disable a limit. - For debugging, set ACTON_SSH_DEBUG and ACTON_SSH_LIBSSH_LOG. """ @@ -69,6 +69,466 @@ Notes: import net +class Algorithm: + """An algorithm supported by the bundled libssh build.""" + _name: str + def __init__(self, name: str): + self._name = name + def __str__(self) -> str: + return self._name + + +class Cipher(Algorithm): + """A cipher supported by the bundled libssh build.""" + pass + + +class Mac(Algorithm): + """A message authentication code supported by bundled libssh.""" + pass + + +class KeyExchange(Algorithm): + """A key exchange algorithm supported by bundled libssh.""" + pass + + +class HostKeyAlgorithm(Algorithm): + """A server host key algorithm supported by bundled libssh.""" + pass + + +class PublicKeyAlgorithm(Algorithm): + """A public key authentication algorithm supported by bundled libssh.""" + pass + + +class CompressionAlgorithm(Algorithm): + """A compression algorithm supported by bundled libssh.""" + pass + + +# These catalogs are exhaustive for the pinned libssh build. Keep their order +# aligned with libssh's supported/default method tables when updating the pin. +_cipher_chacha20_poly1305 = Cipher("chacha20-poly1305@openssh.com") +_cipher_aes256_gcm = Cipher("aes256-gcm@openssh.com") +_cipher_aes128_gcm = Cipher("aes128-gcm@openssh.com") +_cipher_aes256_ctr = Cipher("aes256-ctr") +_cipher_aes192_ctr = Cipher("aes192-ctr") +_cipher_aes128_ctr = Cipher("aes128-ctr") +_cipher_aes256_cbc = Cipher("aes256-cbc") +_cipher_aes192_cbc = Cipher("aes192-cbc") +_cipher_aes128_cbc = Cipher("aes128-cbc") +_cipher_triple_des_cbc = Cipher("3des-cbc") +_cipher_none = Cipher("none") + +cipher = ( + chacha20_poly1305=_cipher_chacha20_poly1305, + aes256_gcm=_cipher_aes256_gcm, + aes128_gcm=_cipher_aes128_gcm, + aes256_ctr=_cipher_aes256_ctr, + aes192_ctr=_cipher_aes192_ctr, + aes128_ctr=_cipher_aes128_ctr, + aes256_cbc=_cipher_aes256_cbc, + aes192_cbc=_cipher_aes192_cbc, + aes128_cbc=_cipher_aes128_cbc, + triple_des_cbc=_cipher_triple_des_cbc, + none=_cipher_none, + default=[_cipher_chacha20_poly1305, + _cipher_aes256_gcm, _cipher_aes128_gcm, + _cipher_aes256_ctr, _cipher_aes192_ctr, _cipher_aes128_ctr], + supported=[_cipher_chacha20_poly1305, + _cipher_aes256_gcm, _cipher_aes128_gcm, + _cipher_aes256_ctr, _cipher_aes192_ctr, _cipher_aes128_ctr, + _cipher_aes256_cbc, _cipher_aes192_cbc, _cipher_aes128_cbc, + _cipher_triple_des_cbc, _cipher_none], +) + +_cipher_supported_names = {str(value) for value in cipher.supported} + + +_mac_hmac_sha2_256_etm = Mac("hmac-sha2-256-etm@openssh.com") +_mac_hmac_sha2_512_etm = Mac("hmac-sha2-512-etm@openssh.com") +_mac_hmac_sha1_etm = Mac("hmac-sha1-etm@openssh.com") +_mac_hmac_sha2_256 = Mac("hmac-sha2-256") +_mac_hmac_sha2_512 = Mac("hmac-sha2-512") +_mac_hmac_sha1 = Mac("hmac-sha1") +_mac_none = Mac("none") + +mac = ( + hmac_sha2_256_etm=_mac_hmac_sha2_256_etm, + hmac_sha2_512_etm=_mac_hmac_sha2_512_etm, + hmac_sha1_etm=_mac_hmac_sha1_etm, + hmac_sha2_256=_mac_hmac_sha2_256, + hmac_sha2_512=_mac_hmac_sha2_512, + hmac_sha1=_mac_hmac_sha1, + none=_mac_none, + default=[_mac_hmac_sha2_256_etm, _mac_hmac_sha2_512_etm, + _mac_hmac_sha2_256, _mac_hmac_sha2_512], + supported=[_mac_hmac_sha2_256_etm, _mac_hmac_sha2_512_etm, + _mac_hmac_sha1_etm, _mac_hmac_sha2_256, + _mac_hmac_sha2_512, _mac_hmac_sha1, _mac_none], +) + +_mac_supported_names = {str(value) for value in mac.supported} + + +_kex_group_exchange_sha1 = KeyExchange("diffie-hellman-group-exchange-sha1") +_kex_mlkem768x25519_sha256 = KeyExchange("mlkem768x25519-sha256") +_kex_mlkem768nistp256_sha256 = KeyExchange("mlkem768nistp256-sha256") +_kex_sntrup761x25519_sha512 = KeyExchange("sntrup761x25519-sha512") +_kex_sntrup761x25519_sha512_openssh = KeyExchange("sntrup761x25519-sha512@openssh.com") +_kex_curve25519_sha256 = KeyExchange("curve25519-sha256") +_kex_curve25519_sha256_libssh = KeyExchange("curve25519-sha256@libssh.org") +_kex_ecdh_sha2_nistp256 = KeyExchange("ecdh-sha2-nistp256") +_kex_ecdh_sha2_nistp384 = KeyExchange("ecdh-sha2-nistp384") +_kex_ecdh_sha2_nistp521 = KeyExchange("ecdh-sha2-nistp521") +_kex_group18_sha512 = KeyExchange("diffie-hellman-group18-sha512") +_kex_group16_sha512 = KeyExchange("diffie-hellman-group16-sha512") +_kex_group_exchange_sha256 = KeyExchange("diffie-hellman-group-exchange-sha256") +_kex_group14_sha256 = KeyExchange("diffie-hellman-group14-sha256") +_kex_group14_sha1 = KeyExchange("diffie-hellman-group14-sha1") +_kex_group1_sha1 = KeyExchange("diffie-hellman-group1-sha1") + +kex = ( + diffie_hellman_group_exchange_sha1=_kex_group_exchange_sha1, + mlkem768x25519_sha256=_kex_mlkem768x25519_sha256, + mlkem768nistp256_sha256=_kex_mlkem768nistp256_sha256, + sntrup761x25519_sha512=_kex_sntrup761x25519_sha512, + sntrup761x25519_sha512_openssh=_kex_sntrup761x25519_sha512_openssh, + curve25519_sha256=_kex_curve25519_sha256, + curve25519_sha256_libssh=_kex_curve25519_sha256_libssh, + ecdh_sha2_nistp256=_kex_ecdh_sha2_nistp256, + ecdh_sha2_nistp384=_kex_ecdh_sha2_nistp384, + ecdh_sha2_nistp521=_kex_ecdh_sha2_nistp521, + diffie_hellman_group18_sha512=_kex_group18_sha512, + diffie_hellman_group16_sha512=_kex_group16_sha512, + diffie_hellman_group_exchange_sha256=_kex_group_exchange_sha256, + diffie_hellman_group14_sha256=_kex_group14_sha256, + diffie_hellman_group14_sha1=_kex_group14_sha1, + diffie_hellman_group1_sha1=_kex_group1_sha1, + default=[_kex_mlkem768x25519_sha256, _kex_mlkem768nistp256_sha256, + _kex_sntrup761x25519_sha512, + _kex_sntrup761x25519_sha512_openssh, + _kex_curve25519_sha256, _kex_curve25519_sha256_libssh, + _kex_ecdh_sha2_nistp256, _kex_ecdh_sha2_nistp384, + _kex_ecdh_sha2_nistp521, _kex_group18_sha512, + _kex_group16_sha512, _kex_group_exchange_sha256, + _kex_group14_sha256], + supported=[_kex_group_exchange_sha1, + _kex_mlkem768x25519_sha256, _kex_mlkem768nistp256_sha256, + _kex_sntrup761x25519_sha512, + _kex_sntrup761x25519_sha512_openssh, + _kex_curve25519_sha256, _kex_curve25519_sha256_libssh, + _kex_ecdh_sha2_nistp256, _kex_ecdh_sha2_nistp384, + _kex_ecdh_sha2_nistp521, _kex_group18_sha512, + _kex_group16_sha512, _kex_group_exchange_sha256, + _kex_group14_sha256, _kex_group14_sha1, _kex_group1_sha1], +) + +_kex_supported_names = {str(value) for value in kex.supported} + + +_hostkey_ssh_ed25519_cert = HostKeyAlgorithm("ssh-ed25519-cert-v01@openssh.com") +_hostkey_sk_ssh_ed25519_cert = HostKeyAlgorithm("sk-ssh-ed25519-cert-v01@openssh.com") +_hostkey_ecdsa_nistp521_cert = HostKeyAlgorithm("ecdsa-sha2-nistp521-cert-v01@openssh.com") +_hostkey_ecdsa_nistp384_cert = HostKeyAlgorithm("ecdsa-sha2-nistp384-cert-v01@openssh.com") +_hostkey_ecdsa_nistp256_cert = HostKeyAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com") +_hostkey_sk_ecdsa_nistp256_cert = HostKeyAlgorithm("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com") +_hostkey_rsa_sha2_512_cert = HostKeyAlgorithm("rsa-sha2-512-cert-v01@openssh.com") +_hostkey_rsa_sha2_256_cert = HostKeyAlgorithm("rsa-sha2-256-cert-v01@openssh.com") +_hostkey_ssh_rsa_cert = HostKeyAlgorithm("ssh-rsa-cert-v01@openssh.com") +_hostkey_ssh_ed25519 = HostKeyAlgorithm("ssh-ed25519") +_hostkey_ecdsa_nistp521 = HostKeyAlgorithm("ecdsa-sha2-nistp521") +_hostkey_ecdsa_nistp384 = HostKeyAlgorithm("ecdsa-sha2-nistp384") +_hostkey_ecdsa_nistp256 = HostKeyAlgorithm("ecdsa-sha2-nistp256") +_hostkey_sk_ssh_ed25519 = HostKeyAlgorithm("sk-ssh-ed25519@openssh.com") +_hostkey_sk_ecdsa_nistp256 = HostKeyAlgorithm("sk-ecdsa-sha2-nistp256@openssh.com") +_hostkey_rsa_sha2_512 = HostKeyAlgorithm("rsa-sha2-512") +_hostkey_rsa_sha2_256 = HostKeyAlgorithm("rsa-sha2-256") +_hostkey_ssh_rsa = HostKeyAlgorithm("ssh-rsa") + +hostkey = ( + ssh_ed25519_cert=_hostkey_ssh_ed25519_cert, + sk_ssh_ed25519_cert=_hostkey_sk_ssh_ed25519_cert, + ecdsa_sha2_nistp521_cert=_hostkey_ecdsa_nistp521_cert, + ecdsa_sha2_nistp384_cert=_hostkey_ecdsa_nistp384_cert, + ecdsa_sha2_nistp256_cert=_hostkey_ecdsa_nistp256_cert, + sk_ecdsa_sha2_nistp256_cert=_hostkey_sk_ecdsa_nistp256_cert, + rsa_sha2_512_cert=_hostkey_rsa_sha2_512_cert, + rsa_sha2_256_cert=_hostkey_rsa_sha2_256_cert, + ssh_rsa_cert=_hostkey_ssh_rsa_cert, + ssh_ed25519=_hostkey_ssh_ed25519, + ecdsa_sha2_nistp521=_hostkey_ecdsa_nistp521, + ecdsa_sha2_nistp384=_hostkey_ecdsa_nistp384, + ecdsa_sha2_nistp256=_hostkey_ecdsa_nistp256, + sk_ssh_ed25519=_hostkey_sk_ssh_ed25519, + sk_ecdsa_sha2_nistp256=_hostkey_sk_ecdsa_nistp256, + rsa_sha2_512=_hostkey_rsa_sha2_512, + rsa_sha2_256=_hostkey_rsa_sha2_256, + ssh_rsa=_hostkey_ssh_rsa, + default=[_hostkey_ssh_ed25519_cert, + _hostkey_ecdsa_nistp521_cert, _hostkey_ecdsa_nistp384_cert, + _hostkey_ecdsa_nistp256_cert, _hostkey_sk_ecdsa_nistp256_cert, + _hostkey_rsa_sha2_512_cert, _hostkey_rsa_sha2_256_cert, + _hostkey_ssh_ed25519, _hostkey_ecdsa_nistp521, + _hostkey_ecdsa_nistp384, _hostkey_ecdsa_nistp256, + _hostkey_sk_ssh_ed25519, _hostkey_sk_ecdsa_nistp256, + _hostkey_rsa_sha2_512, _hostkey_rsa_sha2_256], + supported=[_hostkey_ssh_ed25519_cert, _hostkey_sk_ssh_ed25519_cert, + _hostkey_ecdsa_nistp521_cert, _hostkey_ecdsa_nistp384_cert, + _hostkey_ecdsa_nistp256_cert, _hostkey_sk_ecdsa_nistp256_cert, + _hostkey_rsa_sha2_512_cert, _hostkey_rsa_sha2_256_cert, + _hostkey_ssh_rsa_cert, _hostkey_ssh_ed25519, + _hostkey_ecdsa_nistp521, _hostkey_ecdsa_nistp384, + _hostkey_ecdsa_nistp256, _hostkey_sk_ssh_ed25519, + _hostkey_sk_ecdsa_nistp256, _hostkey_rsa_sha2_512, + _hostkey_rsa_sha2_256, _hostkey_ssh_rsa], +) + +_hostkey_supported_names = {str(value) for value in hostkey.supported} + + +_pubkey_ssh_ed25519_cert = PublicKeyAlgorithm("ssh-ed25519-cert-v01@openssh.com") +_pubkey_sk_ssh_ed25519_cert = PublicKeyAlgorithm("sk-ssh-ed25519-cert-v01@openssh.com") +_pubkey_ecdsa_nistp521_cert = PublicKeyAlgorithm("ecdsa-sha2-nistp521-cert-v01@openssh.com") +_pubkey_ecdsa_nistp384_cert = PublicKeyAlgorithm("ecdsa-sha2-nistp384-cert-v01@openssh.com") +_pubkey_ecdsa_nistp256_cert = PublicKeyAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com") +_pubkey_sk_ecdsa_nistp256_cert = PublicKeyAlgorithm("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com") +_pubkey_rsa_sha2_512_cert = PublicKeyAlgorithm("rsa-sha2-512-cert-v01@openssh.com") +_pubkey_rsa_sha2_256_cert = PublicKeyAlgorithm("rsa-sha2-256-cert-v01@openssh.com") +_pubkey_ssh_rsa_cert = PublicKeyAlgorithm("ssh-rsa-cert-v01@openssh.com") +_pubkey_ssh_ed25519 = PublicKeyAlgorithm("ssh-ed25519") +_pubkey_ecdsa_nistp521 = PublicKeyAlgorithm("ecdsa-sha2-nistp521") +_pubkey_ecdsa_nistp384 = PublicKeyAlgorithm("ecdsa-sha2-nistp384") +_pubkey_ecdsa_nistp256 = PublicKeyAlgorithm("ecdsa-sha2-nistp256") +_pubkey_sk_ssh_ed25519 = PublicKeyAlgorithm("sk-ssh-ed25519@openssh.com") +_pubkey_sk_ecdsa_nistp256 = PublicKeyAlgorithm("sk-ecdsa-sha2-nistp256@openssh.com") +_pubkey_rsa_sha2_512 = PublicKeyAlgorithm("rsa-sha2-512") +_pubkey_rsa_sha2_256 = PublicKeyAlgorithm("rsa-sha2-256") +_pubkey_ssh_rsa = PublicKeyAlgorithm("ssh-rsa") + +pubkey = ( + ssh_ed25519_cert=_pubkey_ssh_ed25519_cert, + sk_ssh_ed25519_cert=_pubkey_sk_ssh_ed25519_cert, + ecdsa_sha2_nistp521_cert=_pubkey_ecdsa_nistp521_cert, + ecdsa_sha2_nistp384_cert=_pubkey_ecdsa_nistp384_cert, + ecdsa_sha2_nistp256_cert=_pubkey_ecdsa_nistp256_cert, + sk_ecdsa_sha2_nistp256_cert=_pubkey_sk_ecdsa_nistp256_cert, + rsa_sha2_512_cert=_pubkey_rsa_sha2_512_cert, + rsa_sha2_256_cert=_pubkey_rsa_sha2_256_cert, + ssh_rsa_cert=_pubkey_ssh_rsa_cert, + ssh_ed25519=_pubkey_ssh_ed25519, + ecdsa_sha2_nistp521=_pubkey_ecdsa_nistp521, + ecdsa_sha2_nistp384=_pubkey_ecdsa_nistp384, + ecdsa_sha2_nistp256=_pubkey_ecdsa_nistp256, + sk_ssh_ed25519=_pubkey_sk_ssh_ed25519, + sk_ecdsa_sha2_nistp256=_pubkey_sk_ecdsa_nistp256, + rsa_sha2_512=_pubkey_rsa_sha2_512, + rsa_sha2_256=_pubkey_rsa_sha2_256, + ssh_rsa=_pubkey_ssh_rsa, + default=[_pubkey_ssh_ed25519_cert, + _pubkey_ecdsa_nistp521_cert, _pubkey_ecdsa_nistp384_cert, + _pubkey_ecdsa_nistp256_cert, _pubkey_sk_ecdsa_nistp256_cert, + _pubkey_rsa_sha2_512_cert, _pubkey_rsa_sha2_256_cert, + _pubkey_ssh_ed25519, _pubkey_ecdsa_nistp521, + _pubkey_ecdsa_nistp384, _pubkey_ecdsa_nistp256, + _pubkey_sk_ssh_ed25519, _pubkey_sk_ecdsa_nistp256, + _pubkey_rsa_sha2_512, _pubkey_rsa_sha2_256], + supported=[_pubkey_ssh_ed25519_cert, _pubkey_sk_ssh_ed25519_cert, + _pubkey_ecdsa_nistp521_cert, _pubkey_ecdsa_nistp384_cert, + _pubkey_ecdsa_nistp256_cert, _pubkey_sk_ecdsa_nistp256_cert, + _pubkey_rsa_sha2_512_cert, _pubkey_rsa_sha2_256_cert, + _pubkey_ssh_rsa_cert, _pubkey_ssh_ed25519, + _pubkey_ecdsa_nistp521, _pubkey_ecdsa_nistp384, + _pubkey_ecdsa_nistp256, _pubkey_sk_ssh_ed25519, + _pubkey_sk_ecdsa_nistp256, _pubkey_rsa_sha2_512, + _pubkey_rsa_sha2_256, _pubkey_ssh_rsa], +) + +_pubkey_supported_names = {str(value) for value in pubkey.supported} + + +_compression_none = CompressionAlgorithm("none") +_compression_zlib_openssh = CompressionAlgorithm("zlib@openssh.com") +_compression_zlib = CompressionAlgorithm("zlib") + +compression = ( + none=_compression_none, + zlib_openssh=_compression_zlib_openssh, + zlib=_compression_zlib, + default=[_compression_none, _compression_zlib_openssh], + supported=[_compression_none, _compression_zlib_openssh, _compression_zlib], +) + +_compression_supported_names = {str(value) for value in compression.supported} + + +def _validated_algorithm_names(names: list[str], supported: set[str], kind: str) -> str: + if not names: + raise ValueError(f"TransportConfig {kind} cannot be empty") + seen: set[str] = set() + for name in names: + if name not in supported: + raise ValueError(f"Unsupported SSH {kind}: {name}") + if name in seen: + raise ValueError(f"Duplicate SSH {kind}: {name}") + seen.add(name) + return ",".join(names) + + +class TransportConfig(object): + """Shared client/server SSH negotiation and rekey configuration. + + Each algorithm list is the exact ordered preference list for both + directions and defaults to the library default (ssh.cipher.default and + friends). minimum_rsa_bits defaults to 1024, the lowest supported. + rekey_after_bytes None rekeys at the negotiated cipher's RFC 4344 + volume limit; a set value can only lower that limit, not raise it. + rekey_after_seconds None disables time-based rekeying. + """ + ciphers: list[Cipher] + macs: list[Mac] + key_exchanges: list[KeyExchange] + host_key_algorithms: list[HostKeyAlgorithm] + public_key_algorithms: list[PublicKeyAlgorithm] + compression_algorithms: list[CompressionAlgorithm] + compression_level: int + rekey_after_bytes: ?int + rekey_after_seconds: ?int + minimum_rsa_bits: int + + def __init__(self, + ciphers: ?list[Cipher]=None, + macs: ?list[Mac]=None, + key_exchanges: ?list[KeyExchange]=None, + host_key_algorithms: ?list[HostKeyAlgorithm]=None, + public_key_algorithms: ?list[PublicKeyAlgorithm]=None, + compression_algorithms: ?list[CompressionAlgorithm]=None, + compression_level: int=7, + rekey_after_bytes: ?int=None, + rekey_after_seconds: ?int=None, + minimum_rsa_bits: int=1024): + self.ciphers = list(ciphers) if ciphers is not None else list(cipher.default) + self.macs = list(macs) if macs is not None else list(mac.default) + self.key_exchanges = list(key_exchanges) if key_exchanges is not None else list(kex.default) + self.host_key_algorithms = list(host_key_algorithms) if host_key_algorithms is not None else list(hostkey.default) + self.public_key_algorithms = list(public_key_algorithms) if public_key_algorithms is not None else list(pubkey.default) + self.compression_algorithms = list(compression_algorithms) if compression_algorithms is not None else list(compression.default) + self.compression_level = compression_level + self.rekey_after_bytes = rekey_after_bytes + self.rekey_after_seconds = rekey_after_seconds + self.minimum_rsa_bits = minimum_rsa_bits + self._native_snapshot() + + def _native_snapshot(self): + ciphers = self._ciphers_csv() + macs = self._macs_csv() + key_exchanges = self._key_exchanges_csv() + host_key_algorithms = self._host_key_algorithms_csv() + public_key_algorithms = self._public_key_algorithms_csv() + compression_algorithms = self._compression_algorithms_csv() + level = self.compression_level + if level < 1 or level > 9: + raise ValueError("SSH compression_level must be between 1 and 9") + rekey_bytes = self.rekey_after_bytes + if rekey_bytes is not None and rekey_bytes < 0: + raise ValueError("SSH rekey_after_bytes cannot be negative") + rekey_seconds = self.rekey_after_seconds + if rekey_seconds is not None: + if rekey_seconds < 0: + raise ValueError("SSH rekey_after_seconds cannot be negative") + if rekey_seconds > 4294967: + raise ValueError("SSH rekey_after_seconds cannot exceed 4294967") + rsa_bits = self.minimum_rsa_bits + if rsa_bits < 1024: + raise ValueError("SSH minimum_rsa_bits must be at least 1024") + if rsa_bits > 2147483647: + raise ValueError("SSH minimum_rsa_bits cannot exceed 2147483647") + return ( + ciphers=ciphers, + macs=macs, + key_exchanges=key_exchanges, + host_key_algorithms=host_key_algorithms, + public_key_algorithms=public_key_algorithms, + compression_algorithms=compression_algorithms, + compression_level=level, + rekey_after_bytes=rekey_bytes if rekey_bytes is not None else -1, + rekey_after_seconds=rekey_seconds if rekey_seconds is not None else -1, + minimum_rsa_bits=rsa_bits, + ) + + def _ciphers_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.ciphers], + _cipher_supported_names, + "cipher", + ) + + def _macs_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.macs], + _mac_supported_names, + "MAC", + ) + + def _key_exchanges_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.key_exchanges], + _kex_supported_names, + "key exchange algorithm", + ) + + def _host_key_algorithms_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.host_key_algorithms], + _hostkey_supported_names, + "host key algorithm", + ) + + def _public_key_algorithms_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.public_key_algorithms], + _pubkey_supported_names, + "public key algorithm", + ) + + def _compression_algorithms_csv(self) -> str: + return _validated_algorithm_names( + [str(value) for value in self.compression_algorithms], + _compression_supported_names, + "compression algorithm", + ) + + +def _native_transport_snapshot(transport: ?TransportConfig): + """Snapshot the supplied config, or the default configuration.""" + t = transport if transport is not None else TransportConfig() + return t._native_snapshot() + + +class PrivateKey(object): + """In-memory OpenSSH/PEM private key and optional OpenSSH certificate.""" + material: bytes + passphrase: ?str + certificate: ?bytes + + def __init__(self, material: bytes, passphrase: ?str=None, certificate: ?bytes=None): + if len(material) == 0: + raise ValueError("SSH private key material cannot be empty") + if material.find(b"\x00") >= 0: + raise ValueError("SSH private key material cannot contain NUL bytes") + if passphrase is not None and passphrase.find("\x00") >= 0: + raise ValueError("SSH private key passphrase cannot contain NUL characters") + if certificate is not None and len(certificate) == 0: + raise ValueError("SSH certificate material cannot be empty") + if certificate is not None and certificate.find(b"\x00") >= 0: + raise ValueError("SSH certificate material cannot contain NUL bytes") + self.material = material + self.passphrase = passphrase + self.certificate = certificate + + def version() -> str: """Get the libssh version""" NotImplemented @@ -131,10 +591,10 @@ actor Client(cap: net.TCPConnectCap, on_close: action(Client, str) -> None, on_hostkey: ?action(Client, str, HostKeyInfo) -> None=None, password: ?str=None, - private_key_file: ?str=None, - private_key_passphrase: ?str=None, + private_keys: ?list[PrivateKey]=None, port: u16=22, - known_hosts: ?str=None, + known_hosts: ?bytes=None, + transport: ?TransportConfig=None, connect_timeout: float=10.0, auth_timeout: float=10.0, keepalive_interval: float=30.0, @@ -148,18 +608,37 @@ actor Client(cap: net.TCPConnectCap, err is None on success, otherwise a description of the failure. on_close(reason) fires exactly once after a successful connect when the session ends. on_hostkey is invoked for host key verification when the - key is not already trusted via known_hosts; respond with accept_hostkey() - or reject_hostkey(reason). + key is not already trusted via the in-memory known_hosts data; respond + with accept_hostkey() or reject_hostkey(reason). known_hosts does not + influence which host key type is negotiated; set + transport.host_key_algorithms to pin one. @cert-authority and @revoked + marker lines are ignored, as in libssh itself. private_keys are tried in + order. transport contains only shared negotiation and rekey settings. """ # Pointer to the native client context (opaque) var _client: u64 = 0 var _host: str = host var _username: str = username + if private_keys is not None and len(private_keys) == 0: + raise ValueError("SSH private_keys cannot be empty") + if known_hosts is not None and known_hosts.find(b"\x00") >= 0: + raise ValueError("SSH known_hosts data cannot contain NUL bytes") + transport_snapshot = _native_transport_snapshot(transport) + var _password: ?str = password - var _private_key_file: ?str = private_key_file - var _private_key_passphrase: ?str = private_key_passphrase - var _known_hosts: ?str = known_hosts + var _private_keys: ?list[PrivateKey] = list(private_keys) if private_keys is not None else None + var _known_hosts: ?bytes = known_hosts + var _ciphers: ?str = transport_snapshot.ciphers + var _macs: ?str = transport_snapshot.macs + var _key_exchanges: ?str = transport_snapshot.key_exchanges + var _host_key_algorithms: ?str = transport_snapshot.host_key_algorithms + var _public_key_algorithms: ?str = transport_snapshot.public_key_algorithms + var _compression_algorithms: ?str = transport_snapshot.compression_algorithms + var _compression_level: int = transport_snapshot.compression_level + var _rekey_after_bytes: int = transport_snapshot.rekey_after_bytes + var _rekey_after_seconds: int = transport_snapshot.rekey_after_seconds + var _minimum_rsa_bits: int = transport_snapshot.minimum_rsa_bits var _connect_timeout: float = connect_timeout var _auth_timeout: float = auth_timeout var _keepalive_interval: float = keepalive_interval @@ -393,9 +872,10 @@ actor Server(cap: net.TCPListenCap, on_exec: ?action(ServerSession, ServerChannel, str) -> None=None, on_subsystem: ?action(ServerSession, ServerChannel, str) -> None=None, on_session_close: ?action(ServerSession, str) -> None=None, - host_key: ?str=None, + host_keys: ?list[PrivateKey]=None, host_key_type: str="ed25519", host_key_bits: int=2048, + transport: ?TransportConfig=None, auth_timeout: float=10.0, keepalive_interval: float=30.0, keepalive_enabled: bool=True, @@ -414,15 +894,32 @@ actor Server(cap: net.TCPListenCap, session.reject_auth(reason). Channel opens are delivered via on_channel_open(session); respond with session.accept_channel(ServerChannel(...)) or session.reject_channel(). + host_keys are in-memory keys offered by key type; when omitted, one + ephemeral key is generated from host_key_type/host_key_bits. transport + contains the same shared negotiation and rekey settings used by Client. """ var _server: u64 = 0 var _bound_port: u16 = 0 var _host: str = host + if host_keys is not None and len(host_keys) == 0: + raise ValueError("SSH host_keys cannot be empty") + transport_snapshot = _native_transport_snapshot(transport) + var _port: u16 = port - var _host_key: ?str = host_key + var _host_keys: ?list[PrivateKey] = list(host_keys) if host_keys is not None else None var _host_key_type: str = host_key_type var _host_key_bits: int = host_key_bits + var _ciphers: ?str = transport_snapshot.ciphers + var _macs: ?str = transport_snapshot.macs + var _key_exchanges: ?str = transport_snapshot.key_exchanges + var _host_key_algorithms: ?str = transport_snapshot.host_key_algorithms + var _public_key_algorithms: ?str = transport_snapshot.public_key_algorithms + var _compression_algorithms: ?str = transport_snapshot.compression_algorithms + var _compression_level: int = transport_snapshot.compression_level + var _rekey_after_bytes: int = transport_snapshot.rekey_after_bytes + var _rekey_after_seconds: int = transport_snapshot.rekey_after_seconds + var _minimum_rsa_bits: int = transport_snapshot.minimum_rsa_bits var _auth_timeout: float = auth_timeout var _keepalive_interval: float = keepalive_interval var _keepalive_enabled: bool = keepalive_enabled diff --git a/src/lib.ext.c b/src/lib.ext.c index 669d1c4..024bc6a 100644 --- a/src/lib.ext.c +++ b/src/lib.ext.c @@ -83,10 +83,15 @@ * actor the app dropped runs __cleanup__ -> _cleanup_native -> close. * * Config & filesystem - * - libssh config processing is disabled; known_hosts is only read if - * explicitly configured by the Acton API. + * - libssh config processing is disabled. Private keys, host keys, and + * known_hosts entries are supplied as in-memory values by the Acton API; + * this library never opens those files itself. libssh's own known_hosts + * paths are pinned to /dev/null at session init: left unset, ssh_connect + * would default them to ~/.ssh/known_hosts and /etc/ssh/ssh_known_hosts + * and read both during key exchange to order host key algorithms. * - Server host keys are generated in-memory unless key material is provided. */ +#include #include #include #include @@ -219,6 +224,7 @@ typedef struct ssh_client_ctx { int close_force; int write_ready; ssh_key auth_key; + size_t auth_key_index; int auth_pubkey_done; char *close_reason; enum ssh_known_hosts_e hostkey_state; @@ -1472,24 +1478,397 @@ static int client_get_hostkey_info(ssh_client_ctx *c, B_str *key_type_out, B_str return 0; } +static char *bytes_to_cstring(B_bytes data) { + if (data == NULL) + return NULL; + char *copy = malloc((size_t)data->nbytes + 1); + if (copy == NULL) + return NULL; + memcpy(copy, data->str, (size_t)data->nbytes); + copy[data->nbytes] = '\0'; + return copy; +} + +static void clear_and_free(char *data, size_t len) { + if (data == NULL) + return; + volatile unsigned char *p = (volatile unsigned char *)data; + while (len-- > 0) + *p++ = 0; + free(data); +} + +static int private_key_attach_certificate(ssh_key private_key, + B_bytes certificate, + char *errmsg, + size_t errlen) { + if (certificate == NULL) + return SSH_OK; + + char *line = bytes_to_cstring(certificate); + if (line == NULL) { + snprintf(errmsg, errlen, "Unable to copy SSH certificate material"); + return SSH_ERROR; + } + + char *algorithm = line; + while (*algorithm == ' ' || *algorithm == '\t') + algorithm++; + char *algorithm_end = algorithm; + while (*algorithm_end != '\0' && *algorithm_end != ' ' && *algorithm_end != '\t') + algorithm_end++; + if (*algorithm_end == '\0') { + free(line); + snprintf(errmsg, errlen, "SSH certificate must be an OpenSSH public key line"); + return SSH_ERROR; + } + *algorithm_end++ = '\0'; + while (*algorithm_end == ' ' || *algorithm_end == '\t') + algorithm_end++; + char *encoded = algorithm_end; + while (*algorithm_end != '\0' && *algorithm_end != ' ' && + *algorithm_end != '\t' && *algorithm_end != '\r' && *algorithm_end != '\n') + algorithm_end++; + *algorithm_end = '\0'; + + enum ssh_keytypes_e type = ssh_key_type_from_name(algorithm); + ssh_key cert_key = NULL; + int rc = SSH_ERROR; + if (type != SSH_KEYTYPE_UNKNOWN && encoded[0] != '\0') + rc = ssh_pki_import_cert_base64(encoded, type, &cert_key); + if (rc == SSH_OK && cert_key != NULL) + rc = ssh_pki_copy_cert_to_privkey(cert_key, private_key); + + ssh_key_free(cert_key); + free(line); + if (rc != SSH_OK) { + snprintf(errmsg, errlen, "Failed to import SSH certificate material"); + return SSH_ERROR; + } + return SSH_OK; +} + +static int import_private_key(sshQ_libQ_PrivateKey key_value, + ssh_key *key_out, + char *errmsg, + size_t errlen) { + if (key_value == NULL || key_value->material == NULL || + key_value->material->nbytes == 0) { + snprintf(errmsg, errlen, "SSH private key material is empty"); + return SSH_ERROR; + } + + B_bytes material = key_value->material; + char *encoded = bytes_to_cstring(material); + if (encoded == NULL) { + snprintf(errmsg, errlen, "Unable to copy SSH private key material"); + return SSH_ERROR; + } + const char *passphrase = key_value->passphrase != NULL ? + (const char *)fromB_str(key_value->passphrase) : NULL; + ssh_key key = NULL; + int rc = ssh_pki_import_privkey_base64(encoded, passphrase, NULL, NULL, &key); + clear_and_free(encoded, (size_t)material->nbytes + 1); + if (rc != SSH_OK || key == NULL) { + ssh_key_free(key); + snprintf(errmsg, errlen, "Failed to import SSH private key material"); + return SSH_ERROR; + } + + rc = private_key_attach_certificate(key, key_value->certificate, errmsg, errlen); + if (rc != SSH_OK) { + ssh_key_free(key); + return SSH_ERROR; + } + *key_out = key; + return SSH_OK; +} + +static int session_set_algorithm(ssh_session session, + enum ssh_options_e option, + B_str algorithms, + const char *label, + char *errmsg, + size_t errlen) { + if (algorithms == NULL) + return SSH_OK; + if (ssh_options_set(session, option, fromB_str(algorithms)) == SSH_OK) + return SSH_OK; + snprintf(errmsg, errlen, "Failed to set SSH %s: %s", + label, ssh_get_error(session)); + return SSH_ERROR; +} + +static int bind_set_algorithm(ssh_bind bind, + enum ssh_bind_options_e option, + B_str algorithms, + const char *label, + char *errmsg, + size_t errlen) { + if (algorithms == NULL) + return SSH_OK; + if (ssh_bind_options_set(bind, option, fromB_str(algorithms)) == SSH_OK) + return SSH_OK; + snprintf(errmsg, errlen, "Failed to set SSH server %s: %s", + label, ssh_get_error(bind)); + return SSH_ERROR; +} + +static int apply_client_transport(ssh_session session, + sshQ_libQ_Client actor, + char *errmsg, + size_t errlen) { + if (session_set_algorithm(session, SSH_OPTIONS_CIPHERS_C_S, + actor->_ciphers, "client-to-server ciphers", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_CIPHERS_S_C, + actor->_ciphers, "server-to-client ciphers", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_HMAC_C_S, + actor->_macs, "client-to-server MACs", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_HMAC_S_C, + actor->_macs, "server-to-client MACs", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_KEY_EXCHANGE, + actor->_key_exchanges, "key exchanges", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_HOSTKEYS, + actor->_host_key_algorithms, "host key algorithms", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_PUBLICKEY_ACCEPTED_TYPES, + actor->_public_key_algorithms, + "public key algorithms", errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_COMPRESSION_C_S, + actor->_compression_algorithms, + "client-to-server compression algorithms", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_COMPRESSION_S_C, + actor->_compression_algorithms, + "server-to-client compression algorithms", + errmsg, errlen) != SSH_OK) { + return SSH_ERROR; + } + + if (actor->_compression_level > 0) { + int level = (int)actor->_compression_level; + if (ssh_options_set(session, SSH_OPTIONS_COMPRESSION_LEVEL, &level) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH compression level: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + if (actor->_rekey_after_bytes >= 0) { + uint64_t bytes = (uint64_t)actor->_rekey_after_bytes; + if (ssh_options_set(session, SSH_OPTIONS_REKEY_DATA, &bytes) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH rekey byte limit: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + if (actor->_rekey_after_seconds >= 0) { + uint32_t seconds = (uint32_t)actor->_rekey_after_seconds; + if (ssh_options_set(session, SSH_OPTIONS_REKEY_TIME, &seconds) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH rekey time limit: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + if (actor->_minimum_rsa_bits >= 0) { + int bits = (int)actor->_minimum_rsa_bits; + if (ssh_options_set(session, SSH_OPTIONS_RSA_MIN_SIZE, &bits) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH minimum RSA size: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + return SSH_OK; +} + +static int apply_server_bind_transport(ssh_bind bind, + sshQ_libQ_Server actor, + char *errmsg, + size_t errlen) { + if (bind_set_algorithm(bind, SSH_BIND_OPTIONS_CIPHERS_C_S, + actor->_ciphers, "client-to-server ciphers", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_CIPHERS_S_C, + actor->_ciphers, "server-to-client ciphers", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_HMAC_C_S, + actor->_macs, "client-to-server MACs", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_HMAC_S_C, + actor->_macs, "server-to-client MACs", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_KEY_EXCHANGE, + actor->_key_exchanges, "key exchanges", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_HOSTKEY_ALGORITHMS, + actor->_host_key_algorithms, "host key algorithms", + errmsg, errlen) != SSH_OK || + bind_set_algorithm(bind, SSH_BIND_OPTIONS_PUBKEY_ACCEPTED_KEY_TYPES, + actor->_public_key_algorithms, + "public key algorithms", errmsg, errlen) != SSH_OK) { + return SSH_ERROR; + } + + if (actor->_minimum_rsa_bits >= 0) { + int bits = (int)actor->_minimum_rsa_bits; + if (ssh_bind_options_set(bind, SSH_BIND_OPTIONS_RSA_MIN_SIZE, &bits) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH server minimum RSA size: %s", + ssh_get_error(bind)); + return SSH_ERROR; + } + } + return SSH_OK; +} + +static int apply_server_session_transport(ssh_session session, + sshQ_libQ_Server actor, + char *errmsg, + size_t errlen) { + if (session_set_algorithm(session, SSH_OPTIONS_COMPRESSION_C_S, + actor->_compression_algorithms, + "client-to-server compression algorithms", + errmsg, errlen) != SSH_OK || + session_set_algorithm(session, SSH_OPTIONS_COMPRESSION_S_C, + actor->_compression_algorithms, + "server-to-client compression algorithms", + errmsg, errlen) != SSH_OK) { + return SSH_ERROR; + } + + if (actor->_compression_level > 0) { + int level = (int)actor->_compression_level; + if (ssh_options_set(session, SSH_OPTIONS_COMPRESSION_LEVEL, &level) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH server compression level: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + if (actor->_rekey_after_bytes >= 0) { + uint64_t bytes = (uint64_t)actor->_rekey_after_bytes; + if (ssh_options_set(session, SSH_OPTIONS_REKEY_DATA, &bytes) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH server rekey byte limit: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + if (actor->_rekey_after_seconds >= 0) { + uint32_t seconds = (uint32_t)actor->_rekey_after_seconds; + if (ssh_options_set(session, SSH_OPTIONS_REKEY_TIME, &seconds) != SSH_OK) { + snprintf(errmsg, errlen, "Failed to set SSH server rekey time limit: %s", + ssh_get_error(session)); + return SSH_ERROR; + } + } + return SSH_OK; +} + +static enum ssh_known_hosts_e client_known_hosts_state(ssh_client_ctx *c, + B_bytes known_hosts) { + ssh_key server_key = NULL; + if (ssh_get_server_publickey(c->session, &server_key) != SSH_OK || server_key == NULL) + return SSH_KNOWN_HOSTS_ERROR; + + sshQ_libQ_Client actor = client_actor_ref(c); + const char *host = actor != NULL ? (const char *)fromB_str(actor->_host) : NULL; + if (host == NULL) { + ssh_key_free(server_key); + return SSH_KNOWN_HOSTS_ERROR; + } + + char *lookup = NULL; + if (actor->port == 22) { + lookup = strdup(host); + } else { + size_t lookup_len = strlen(host) + 32; + lookup = malloc(lookup_len); + if (lookup != NULL) + snprintf(lookup, lookup_len, "[%s]:%u", host, (unsigned int)actor->port); + } + if (lookup == NULL) { + ssh_key_free(server_key); + return SSH_KNOWN_HOSTS_ERROR; + } + /* libssh's match_hostname() requires the host in all lowercase; it + * lowercases only the known_hosts pattern side. */ + for (char *lc = lookup; *lc != '\0'; lc++) + *lc = (char)tolower((unsigned char)*lc); + + enum ssh_known_hosts_e found = SSH_KNOWN_HOSTS_UNKNOWN; + size_t offset = 0; + while (offset < (size_t)known_hosts->nbytes) { + size_t end = offset; + while (end < (size_t)known_hosts->nbytes && known_hosts->str[end] != '\n') + end++; + size_t len = end - offset; + if (len > 0 && known_hosts->str[offset + len - 1] == '\r') + len--; + + size_t first = 0; + while (first < len && (known_hosts->str[offset + first] == ' ' || + known_hosts->str[offset + first] == '\t')) + first++; + /* Skip @cert-authority / @revoked marker lines like libssh does + * ("we do not completely support them anyway", knownhosts.c); a + * marker's hostname pattern could otherwise never match, so this + * makes the ignore explicit rather than coincidental. */ + if (first < len && known_hosts->str[offset + first] != '#' && + known_hosts->str[offset + first] != '@') { + char *line = malloc(len - first + 1); + if (line == NULL) { + found = SSH_KNOWN_HOSTS_ERROR; + break; + } + memcpy(line, known_hosts->str + offset + first, len - first); + line[len - first] = '\0'; + + struct ssh_knownhosts_entry *entry = NULL; + int rc = ssh_known_hosts_parse_line(lookup, line, &entry); + free(line); + if (rc == SSH_OK && entry != NULL) { + int cmp = ssh_key_cmp(server_key, entry->publickey, SSH_KEY_CMP_PUBLIC); + if (cmp == 0) { + ssh_knownhosts_entry_free(entry); + found = SSH_KNOWN_HOSTS_OK; + break; + } + if (ssh_key_type(server_key) == ssh_key_type(entry->publickey)) { + found = SSH_KNOWN_HOSTS_CHANGED; + } else if (found != SSH_KNOWN_HOSTS_CHANGED) { + found = SSH_KNOWN_HOSTS_OTHER; + } + ssh_knownhosts_entry_free(entry); + } else if (rc == SSH_ERROR) { + SSH_KNOWNHOSTS_ENTRY_FREE(entry); + found = SSH_KNOWN_HOSTS_ERROR; + break; + } + } + offset = end < (size_t)known_hosts->nbytes ? end + 1 : end; + } + + free(lookup); + ssh_key_free(server_key); + return found; +} + /* Returns 0 = hostkey OK (continue to auth), 1 = waiting for app verdict, * -1 = failed (client already failed). */ static int client_check_hostkey(ssh_client_ctx *c) { enum ssh_known_hosts_e state = SSH_KNOWN_HOSTS_UNKNOWN; - int use_known_hosts = 0; sshQ_libQ_Client actor = client_actor_ref(c); - if (actor != NULL && actor->_known_hosts != NULL) - use_known_hosts = 1; - - if (use_known_hosts) { - state = ssh_session_is_known_server(c->session); + if (actor != NULL && actor->_known_hosts != NULL) { + state = client_known_hosts_state(c, actor->_known_hosts); if (state == SSH_KNOWN_HOSTS_OK) return 0; if (state == SSH_KNOWN_HOSTS_ERROR) { char errmsg[256] = {0}; - snprintf(errmsg, sizeof(errmsg), "Host key check error: %s", ssh_get_error(c->session)); + snprintf(errmsg, sizeof(errmsg), "Unable to parse or check SSH known_hosts data"); client_fail(c, errmsg); return -1; } @@ -1527,30 +1906,27 @@ static int client_auth_step(ssh_client_ctx *c, char *errmsg, size_t errlen) { return SSH_AUTH_ERROR; } - if (actor->_private_key_file != NULL && !c->auth_pubkey_done) { - if (c->auth_key == NULL) { - const char *path = (const char *)fromB_str(actor->_private_key_file); - const char *passphrase = actor->_private_key_passphrase != NULL ? - (const char *)fromB_str(actor->_private_key_passphrase) : NULL; - int krc = ssh_pki_import_privkey_file(path, passphrase, NULL, NULL, &c->auth_key); - if (krc != SSH_OK || c->auth_key == NULL) { + B_list private_keys = actor->_private_keys; + if (private_keys != NULL && !c->auth_pubkey_done) { + while (c->auth_key_index < (size_t)private_keys->length) { + if (c->auth_key == NULL) { + sshQ_libQ_PrivateKey key_value = + (sshQ_libQ_PrivateKey)private_keys->data[c->auth_key_index]; + if (import_private_key(key_value, &c->auth_key, errmsg, errlen) != SSH_OK) + return SSH_AUTH_ERROR; + } + int rc = ssh_userauth_publickey(c->session, NULL, c->auth_key); + if (rc == SSH_AUTH_SUCCESS) { + ssh_key_free(c->auth_key); c->auth_key = NULL; - snprintf(errmsg, errlen, "Failed to load SSH private key: %s", path); - return SSH_AUTH_ERROR; + return SSH_AUTH_SUCCESS; } - } - int rc = ssh_userauth_publickey(c->session, NULL, c->auth_key); - if (rc == SSH_AUTH_SUCCESS) { + if (rc == SSH_AUTH_AGAIN) + return SSH_AUTH_AGAIN; ssh_key_free(c->auth_key); c->auth_key = NULL; - return SSH_AUTH_SUCCESS; + c->auth_key_index++; } - if (rc == SSH_AUTH_AGAIN) - return SSH_AUTH_AGAIN; - /* Denied / partial / error: drop the key and optionally fall back to - * password auth. */ - ssh_key_free(c->auth_key); - c->auth_key = NULL; c->auth_pubkey_done = 1; if (actor->_password == NULL) { snprintf(errmsg, errlen, "SSH public key auth failed: %s", ssh_get_error(c->session)); @@ -1568,7 +1944,7 @@ static int client_auth_step(ssh_client_ctx *c, char *errmsg, size_t errlen) { return SSH_AUTH_ERROR; } - snprintf(errmsg, errlen, "No SSH authentication method configured (need password or private_key_file)"); + snprintf(errmsg, errlen, "No SSH authentication method configured (need password or private_keys)"); return SSH_AUTH_ERROR; } @@ -2104,6 +2480,20 @@ B_NoneType sshQ_libQ__debug(B_str msg) { return $R_CONT(c$cont, B_None); } + /* Host key verification uses only the in-memory known_hosts data (see + * client_check_hostkey). Pin libssh's known_hosts paths to /dev/null; + * see the header note. */ + rc = ssh_options_set(c->session, SSH_OPTIONS_KNOWNHOSTS, "/dev/null"); + if (rc != SSH_OK) { + client_fail(c, "Failed to disable SSH known_hosts files"); + return $R_CONT(c$cont, B_None); + } + rc = ssh_options_set(c->session, SSH_OPTIONS_GLOBAL_KNOWNHOSTS, "/dev/null"); + if (rc != SSH_OK) { + client_fail(c, "Failed to disable SSH global known_hosts files"); + return $R_CONT(c$cont, B_None); + } + rc = ssh_options_set(c->session, SSH_OPTIONS_HOST, fromB_str(self->_host)); if (rc != SSH_OK) { client_fail(c, "Failed to set SSH host"); @@ -2119,25 +2509,19 @@ B_NoneType sshQ_libQ__debug(B_str msg) { client_fail(c, "Failed to set SSH username"); return $R_CONT(c$cont, B_None); } - if (self->_known_hosts != NULL) { - const char *known_hosts = (const char *)fromB_str(self->_known_hosts); - rc = ssh_options_set(c->session, SSH_OPTIONS_KNOWNHOSTS, known_hosts); - if (rc != SSH_OK) { - client_fail(c, "Failed to set SSH known_hosts path"); - return $R_CONT(c$cont, B_None); - } - rc = ssh_options_set(c->session, SSH_OPTIONS_GLOBAL_KNOWNHOSTS, known_hosts); - if (rc != SSH_OK) { - client_fail(c, "Failed to set SSH global known_hosts path"); - return $R_CONT(c$cont, B_None); - } - } rc = ssh_options_set(c->session, SSH_OPTIONS_STRICTHOSTKEYCHECK, &strict); if (rc != SSH_OK) { client_fail(c, "Failed to set SSH strict host key checking"); return $R_CONT(c$cont, B_None); } + char transport_error[256] = {0}; + if (apply_client_transport(c->session, self, + transport_error, sizeof(transport_error)) != SSH_OK) { + client_fail(c, transport_error); + return $R_CONT(c$cont, B_None); + } + ssh_set_blocking(c->session, 0); if (ssh_debug_enabled) ssh_debug_log("client init: session=%p blocking=%d", (void *)c->session, ssh_is_blocking(c->session)); @@ -3482,6 +3866,20 @@ static void server_accept(ssh_server_ctx *s) { continue; } + sshQ_libQ_Server act = server_actor_ref(s); + char transport_error[256] = {0}; + if (act == NULL || + apply_server_session_transport(session, act, + transport_error, + sizeof(transport_error)) != SSH_OK) { + log_warn("SSH accept: %s", + transport_error[0] != '\0' ? transport_error : + "server actor gone while applying transport configuration"); + ssh_disconnect(session); + ssh_free(session); + continue; + } + ssh_set_blocking(session, 0); ssh_server_session_ctx *sess = acton_calloc(1, sizeof(ssh_server_session_ctx)); sess->server = s; @@ -3489,7 +3887,6 @@ static void server_accept(ssh_server_ctx *s) { sess->state = SESSION_STATE_KEYEX; sess->pending_id = alloc_pending_session_id(); sess->fd = ssh_get_fd(session); - sshQ_libQ_Server act = server_actor_ref(s); sess->owner_wt = act ? (int)act->$affinity : 0; sess->auth_timeout = act ? act->_auth_timeout : 0.0; if (sess->fd < 0) { @@ -3788,14 +4185,36 @@ static enum ssh_keytypes_e parse_hostkey_type(const char *type_str, int *param_o return $R_CONT(c$cont, B_None); } - if (self->_host_key != NULL) { - const char *key_pem = (const char *)fromB_str(self->_host_key); - rc = ssh_pki_import_privkey_base64(key_pem, NULL, NULL, NULL, &s->hostkey); - if (rc != SSH_OK) { - char errmsg[256] = {0}; - snprintf(errmsg, sizeof(errmsg), "Failed to load host key: %s", ssh_get_error(s->bind)); - server_fail(s, errmsg); - return $R_CONT(c$cont, B_None); + char transport_error[256] = {0}; + if (apply_server_bind_transport(s->bind, self, + transport_error, sizeof(transport_error)) != SSH_OK) { + server_fail(s, transport_error); + return $R_CONT(c$cont, B_None); + } + + if (self->_host_keys != NULL) { + B_list host_keys = self->_host_keys; + for (size_t i = 0; i < (size_t)host_keys->length; i++) { + sshQ_libQ_PrivateKey key_value = + (sshQ_libQ_PrivateKey)host_keys->data[i]; + char key_error[256] = {0}; + if (import_private_key(key_value, &s->hostkey, + key_error, sizeof(key_error)) != SSH_OK) { + server_fail(s, key_error); + return $R_CONT(c$cont, B_None); + } + rc = ssh_bind_options_set(s->bind, + SSH_BIND_OPTIONS_IMPORT_KEY, + s->hostkey); + if (rc != SSH_OK) { + char errmsg[256] = {0}; + snprintf(errmsg, sizeof(errmsg), "Failed to set SSH host key: %s", + ssh_get_error(s->bind)); + server_fail(s, errmsg); + return $R_CONT(c$cont, B_None); + } + /* ssh_bind owns the imported key after a successful set. */ + s->hostkey = NULL; } } else { const char *type_str = (const char *)fromB_str(self->_host_key_type); @@ -3816,16 +4235,15 @@ static enum ssh_keytypes_e parse_hostkey_type(const char *type_str, int *param_o server_fail(s, "Failed to generate host key"); return $R_CONT(c$cont, B_None); } + rc = ssh_bind_options_set(s->bind, SSH_BIND_OPTIONS_IMPORT_KEY, s->hostkey); + if (rc != SSH_OK) { + server_fail(s, "Failed to set host key"); + return $R_CONT(c$cont, B_None); + } + /* ssh_bind takes ownership of IMPORT_KEY and frees it via ssh_bind_free(). */ + s->hostkey = NULL; } - rc = ssh_bind_options_set(s->bind, SSH_BIND_OPTIONS_IMPORT_KEY, s->hostkey); - if (rc != SSH_OK) { - server_fail(s, "Failed to set host key"); - return $R_CONT(c$cont, B_None); - } - /* ssh_bind takes ownership of IMPORT_KEY and frees it via ssh_bind_free(). */ - s->hostkey = NULL; - ssh_bind_set_blocking(s->bind, 0); rc = ssh_bind_listen(s->bind); diff --git a/src/pipe.act b/src/pipe.act index b14ad6a..7dc735f 100644 --- a/src/pipe.act +++ b/src/pipe.act @@ -41,12 +41,12 @@ class SshPipe(Pipe): port: int, username: str, password: ?str=None, - private_key_file: ?str=None, - private_key_passphrase: ?str=None, + private_keys: ?list[ssh.PrivateKey]=None, subsystem: ?str=None, command: ?str=None, host_key_check: str=HOST_KEY_CHECK_ACCEPT_NEW, - known_hosts: ?str=None, + known_hosts: ?bytes=None, + transport: ?ssh.TransportConfig=None, log_handler: ?logging.Handler=None): if subsystem is None and command is None: raise ValueError("SshPipe requires a subsystem or a command") @@ -54,10 +54,10 @@ class SshPipe(Pipe): raise ValueError("SshPipe takes either a subsystem or a command, not both") self._endpoint = _SshClientPipeEndpoint(connect_cap, address, port, username, password, - private_key_file, - private_key_passphrase, + private_keys, subsystem, command, host_key_check, known_hosts, + transport, log_handler) proc def write(self, data: bytes): @@ -78,12 +78,12 @@ actor _SshClientPipeEndpoint(connect_cap: net.TCPConnectCap, port: int, username: str, password: ?str, - private_key_file: ?str, - private_key_passphrase: ?str, + private_keys: ?list[ssh.PrivateKey], subsystem: ?str, command: ?str, host_key_check: str, - known_hosts: ?str, + known_hosts: ?bytes, + transport: ?ssh.TransportConfig, log_handler: ?logging.Handler): _log = logging.Logger(log_handler) @@ -193,10 +193,10 @@ actor _SshClientPipeEndpoint(connect_cap: net.TCPConnectCap, on_close=_on_ssh_close, on_hostkey=_on_hostkey, password=password, - private_key_file=private_key_file, - private_key_passphrase=private_key_passphrase, + private_keys=private_keys, port=u16(port), - known_hosts=known_hosts) + known_hosts=known_hosts, + transport=transport) def write(data: bytes): ch = _channel @@ -344,8 +344,9 @@ actor SshPipeListener(listen_cap: net.TCPListenCap, on_accept: action(SshPipeListener, SshListenPipe) -> None, auth_check: action(str, ?str, ?bytes, action(bool) -> None) -> None, subsystem: ?str=None, - host_key: ?str=None, + host_keys: ?list[ssh.PrivateKey]=None, host_key_type: str="ed25519", + transport: ?ssh.TransportConfig=None, log_handler: ?logging.Handler=None): """Accept SSH connections and deliver subsystem channels as Pipes. @@ -463,5 +464,6 @@ actor SshPipeListener(listen_cap: net.TCPListenCap, on_exec=_on_exec, on_subsystem=_on_subsystem, on_session_close=_on_session_close, - host_key=host_key, - host_key_type=host_key_type) + host_keys=host_keys, + host_key_type=host_key_type, + transport=transport) diff --git a/src/test_interop.act b/src/test_interop.act index 04d01f6..f1c2b6a 100644 --- a/src/test_interop.act +++ b/src/test_interop.act @@ -588,6 +588,9 @@ actor SshdTester( def run_client(): keyfile = tmp + ("/wrong_key" if use_wrong_key else "/client_ed25519") + reader = file.ReadFile(file.ReadFileCap(file.FileCap(t.env.cap)), keyfile) + private_key = await async reader.read() + await async reader.close() client = ssh.Client( net.TCPConnectCap(net.TCPCap(net.NetCap(t.env.cap))), "127.0.0.1", @@ -595,7 +598,7 @@ actor SshdTester( on_connect, on_client_close, on_hostkey, - private_key_file=keyfile, + private_keys=[ssh.PrivateKey(private_key)], port=u16(sshd_port), connect_timeout=10.0, auth_timeout=10.0, diff --git a/src/test_ssh.act b/src/test_ssh.act index c23910f..cc86ed5 100644 --- a/src/test_ssh.act +++ b/src/test_ssh.act @@ -5,7 +5,6 @@ connects an ssh.Client to it over 127.0.0.1. Ephemeral ports make the tests safe to run concurrently (acton test stress). """ -import file import logging import net import lib as ssh @@ -16,9 +15,128 @@ def _test_version(): testing.assertEqual("0.12.2/mbedtls/zlib", ssh.version()) +def _test_transport_config_validation(): + """TransportConfig snapshots inputs and rejects ambiguous values.""" + values = [ssh.cipher.aes128_ctr] + config = ssh.TransportConfig(ciphers=values) + values.append(ssh.cipher.aes256_ctr) + stored = config.ciphers + testing.assertNotNone(stored) + if stored is not None: + testing.assertEqual(1, len(stored)) + + empty: list[ssh.Cipher] = [] + rejected_empty = False + try: + ssh.TransportConfig(ciphers=empty) + except ValueError: + rejected_empty = True + testing.assertTrue(rejected_empty) + + rejected_duplicate = False + try: + ssh.TransportConfig(macs=[ssh.mac.hmac_sha2_256, + ssh.mac.hmac_sha2_256]) + except ValueError: + rejected_duplicate = True + testing.assertTrue(rejected_duplicate) + + rejected_unsupported = False + try: + ssh.TransportConfig(macs=[ssh.Mac("unsupported")]) + except ValueError: + rejected_unsupported = True + testing.assertTrue(rejected_unsupported) + + ssh.TransportConfig(macs=[ssh.Mac("hmac-sha2-256")]) + + rejected_rsa_floor = False + try: + ssh.TransportConfig(minimum_rsa_bits=1023) + except ValueError: + rejected_rsa_floor = True + testing.assertTrue(rejected_rsa_floor) + + ssh.TransportConfig(minimum_rsa_bits=1024) + + # A default-constructed config materializes the library defaults. + defaults = ssh.TransportConfig() + testing.assertEqual([str(v) for v in defaults.ciphers], + [str(v) for v in ssh.cipher.default]) + testing.assertEqual([str(v) for v in defaults.macs], + [str(v) for v in ssh.mac.default]) + testing.assertEqual([str(v) for v in defaults.key_exchanges], + [str(v) for v in ssh.kex.default]) + testing.assertEqual([str(v) for v in defaults.host_key_algorithms], + [str(v) for v in ssh.hostkey.default]) + testing.assertEqual([str(v) for v in defaults.public_key_algorithms], + [str(v) for v in ssh.pubkey.default]) + testing.assertEqual([str(v) for v in defaults.compression_algorithms], + [str(v) for v in ssh.compression.default]) + testing.assertEqual(defaults.compression_level, 7) + testing.assertEqual(defaults.minimum_rsa_bits, 1024) + + rejected_level = False + try: + ssh.TransportConfig(compression_level=10) + except ValueError: + rejected_level = True + testing.assertTrue(rejected_level) + + # Zero volume rekeying keeps the cipher's RFC 4344 limit; zero seconds + # disables time-based rekeying. + ssh.TransportConfig(rekey_after_bytes=0, + rekey_after_seconds=0) + + +def _test_private_key_validation(): + """Private key material stays in memory and must be C-string safe.""" + rejected_empty = False + try: + ssh.PrivateKey(b"") + except ValueError: + rejected_empty = True + testing.assertTrue(rejected_empty) + + rejected_material_nul = False + try: + ssh.PrivateKey(b"key\x00material") + except ValueError: + rejected_material_nul = True + testing.assertTrue(rejected_material_nul) + + rejected_passphrase_nul = False + try: + ssh.PrivateKey(b"key", passphrase="secret\x00suffix") + except ValueError: + rejected_passphrase_nul = True + testing.assertTrue(rejected_passphrase_nul) + + rejected_empty_certificate = False + try: + ssh.PrivateKey(b"key", certificate=b"") + except ValueError: + rejected_empty_certificate = True + testing.assertTrue(rejected_empty_certificate) + + TEST_USER = "testuser" TEST_PASS = "testpass" +def configured_transport() -> ssh.TransportConfig: + return ssh.TransportConfig( + ciphers=[ssh.cipher.aes128_ctr], + macs=[ssh.mac.hmac_sha2_256], + key_exchanges=[ssh.kex.curve25519_sha256], + host_key_algorithms=[ssh.hostkey.ssh_ed25519], + public_key_algorithms=[ssh.pubkey.ssh_ed25519], + compression_algorithms=[ssh.compression.zlib], + compression_level=6, + rekey_after_bytes=1048576, + rekey_after_seconds=3600, + minimum_rsa_bits=3072, + ) + actor ExecTester(t: testing.EnvT): """Client runs a command via RunCommand; server serves it via on_exec.""" @@ -141,6 +259,7 @@ actor ExecTester(t: testing.EnvT): on_hostkey, password=TEST_PASS, port=port, + transport=configured_transport(), ) server = ssh.Server( @@ -153,6 +272,7 @@ actor ExecTester(t: testing.EnvT): on_auth, on_channel_open, on_exec, + transport=configured_transport(), ) after 10.0: on_timeout() @@ -1062,8 +1182,8 @@ def _test_connect_refused(t: testing.EnvT): ConnectRefusedTester(t) -# A throwaway ed25519 keypair generated for tests only. The private key is -# written to a temp file for the client; the server authorizes the matching +# A throwaway ed25519 keypair generated for tests only. The client and server +# both receive it as in-memory material; the server authorizes the matching # public key (authorized_keys " " form, no comment). TEST_PRIVKEY = b"-----BEGIN OPENSSH PRIVATE KEY-----\n" + \ b"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" + \ @@ -1083,12 +1203,9 @@ actor PubkeyAuthTester(t: testing.EnvT): and the presented key in authorized_keys form. """ log = logging.Logger(t.log_handler) - fs = file.FS(file.FileCap(t.env.cap)) - var done = False var server: ?ssh.Server = None var client: ?ssh.Client = None - var keypath = "" var auth_calls = 0 var pubkey_seen = False var run_ok = False @@ -1162,7 +1279,7 @@ actor PubkeyAuthTester(t: testing.EnvT): # --- client side def on_hostkey(c: ssh.Client, state: str, info: ssh.HostKeyInfo): - c.accept_hostkey() + finish_error("in-memory known_hosts did not accept the server key: " + state) def on_connect(c: ssh.Client, err: ?str): if err is not None: @@ -1189,6 +1306,10 @@ actor PubkeyAuthTester(t: testing.EnvT): maybe_finish() def start_client(port: u16): + # The marker line must be ignored (as in libssh); the entry after it + # must still verify the host key. + known_hosts = b"@cert-authority [127.0.0.1]:" + str(port).encode() + b" " + TEST_PUBKEY + b"\n" + known_hosts += ("[127.0.0.1]:" + str(port) + " ").encode() + TEST_PUBKEY + b"\n" client = ssh.Client( net.TCPConnectCap(net.TCPCap(net.NetCap(t.env.cap))), "127.0.0.1", @@ -1196,8 +1317,9 @@ actor PubkeyAuthTester(t: testing.EnvT): on_connect, on_client_close, on_hostkey, - private_key_file=keypath, + private_keys=[ssh.PrivateKey(TEST_PRIVKEY)], port=port, + known_hosts=known_hosts, ) def start_server(): @@ -1211,17 +1333,10 @@ actor PubkeyAuthTester(t: testing.EnvT): on_auth, on_channel_open, on_exec, + host_keys=[ssh.PrivateKey(TEST_PRIVKEY)], ) - def setup(): - dir = await async fs.mktmpdir("acton-ssh-pktest") - keypath = dir + "/id_ed25519" - wf = file.WriteFile(file.WriteFileCap(file.FileCap(t.env.cap)), keypath) - await async wf.write(TEST_PRIVKEY) - await async wf.close() - start_server() - - after 0: setup() + start_server() after 10.0: on_timeout() diff --git a/src/test_ssh_catalog.act b/src/test_ssh_catalog.act new file mode 100644 index 0000000..a3b620b --- /dev/null +++ b/src/test_ssh_catalog.act @@ -0,0 +1,47 @@ +"""Consistency test for the typed SSH algorithm catalogs.""" + +import lib as ssh +import testing + + +_LIBSSH_KEX = 0 +_LIBSSH_HOSTKEY = 1 +_LIBSSH_CIPHER = 2 +_LIBSSH_MAC = 4 +_LIBSSH_COMPRESSION = 6 + + +def _libssh_supported_algorithms(method: int) -> str: + NotImplemented + + +def _libssh_default_algorithms(method: int) -> str: + NotImplemented + + +def _test_transport_catalogs(): + """The typed catalogs must exactly match the pinned libssh tables.""" + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_CIPHER), + ",".join([str(value) for value in ssh.cipher.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_CIPHER), + ",".join([str(value) for value in ssh.cipher.default])) + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_MAC), + ",".join([str(value) for value in ssh.mac.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_MAC), + ",".join([str(value) for value in ssh.mac.default])) + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_KEX), + ",".join([str(value) for value in ssh.kex.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_KEX), + ",".join([str(value) for value in ssh.kex.default])) + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_HOSTKEY), + ",".join([str(value) for value in ssh.hostkey.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_HOSTKEY), + ",".join([str(value) for value in ssh.hostkey.default])) + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_HOSTKEY), + ",".join([str(value) for value in ssh.pubkey.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_HOSTKEY), + ",".join([str(value) for value in ssh.pubkey.default])) + testing.assertEqual(_libssh_supported_algorithms(_LIBSSH_COMPRESSION), + ",".join([str(value) for value in ssh.compression.supported])) + testing.assertEqual(_libssh_default_algorithms(_LIBSSH_COMPRESSION), + ",".join([str(value) for value in ssh.compression.default])) diff --git a/src/test_ssh_catalog.ext.c b/src/test_ssh_catalog.ext.c new file mode 100644 index 0000000..c359ab3 --- /dev/null +++ b/src/test_ssh_catalog.ext.c @@ -0,0 +1,20 @@ +#include + +/* Internal table access used only to verify the typed catalogs in this test. */ +const char *ssh_kex_get_supported_method(enum ssh_kex_types_e algo); +const char *ssh_kex_get_default_methods(enum ssh_kex_types_e algo); + +void sshQ_test_ssh_catalogQ___ext_init__() { +} + +B_str sshQ_test_ssh_catalogQ__libssh_supported_algorithms(int64_t method) { + const char *value = method >= 0 && method < 10 ? + ssh_kex_get_supported_method((enum ssh_kex_types_e)method) : NULL; + return to$str((char *)(value != NULL ? value : "")); +} + +B_str sshQ_test_ssh_catalogQ__libssh_default_algorithms(int64_t method) { + const char *value = method >= 0 && method < 10 ? + ssh_kex_get_default_methods((enum ssh_kex_types_e)method) : NULL; + return to$str((char *)(value != NULL ? value : "")); +}