Files
alkssh/docs/research/russh.md
T
glm-5.3-flash 9d2e962bec docs(architecture): ADR-002 (russh pin) + ADR-003 (feature graph)
- ADR-002 (Accepted): pin russh 0.65 (0.65.0 published 2026-10-25
  after the POCs' 0.64.1 verification — six security fixes incl.
  three in internet-facing classes; breaks are handler-signature
  shapes only, wire evidence transfers); aws-lc-rs backend confirmed
  with a clean in-scratch build record (no provisioning; ring kept as
  the recorded fallback); flate2 off (no compression); rsa on (front-
  door compat — supersedes the research docs' earlier off-leaning;
  RC-tree posture accepted with a recorded stable-release trigger);
  des/dsa never; rust-version 1.89 lands with the dep
- russh.md: re-verification-at-architecture-time section added
  (0.65.0 delta: PublicKeyOffer/VerifiedPublicKey, async-trait
  removed, GHSA list, verified build + tree facts)
- ADR-003 (Accepted): five features (session/git/tunnel/
  dynamic-forward/sftp-placeholder), flat + independent, default [],
  two-direction symmetry rule (one name, both doors), client acceptor
  fail-closed (not russh's accept-and-drop), x11/auth-agent never
  features, honest advertisement per build
2026-10-26 12:16:00 +00:00

15 KiB
Raw Blame History

russh — crate inventory and pin considerations

Status: Draft (phase 0 research). Facts from docs.rs and the GitHub release notes as of 2026-10-25; versions re-verified at architecture time before any pin is recorded.

Version landscape

Re-verified at architecture time (2026-10-26) — see the section at the end of this file. russh 0.65.0 was published 2026-10-25 (after the POCs verified 0.64.1 as newest); the pin ADR is written against 0.65.0. The POC wire-behavior evidence transfers: the 0.65.0 breaking changes are handler-signature shapes, not wire behavior.

  • Current published: russh 0.65.0 (2026-10-25, GitHub releases). Breaking: Handler::auth_publickey_offered now takes a server::PublicKeyOffer and Handler::auth_publickey a server::VerifiedPublicKey (read the key from the .public_key field; a new .algorithm field exposes the client's signature algorithm, allowing server-side reaction to it); the async-trait dep/feature was removed. Security fixes in classes this crate cares about: GHSA-4p9f-7p9m-5vfg (client: unbounded allocations via EXT_INFO packets), GHSA-fw5c-8223-6h42 (peer-declared zero maximum packet size permanently stalls channel writes), GHSA-3hm4-p7h4-rvwp (pre-auth stack exhaustion via nested OpenSSH certificates); the agent/pageant fixes are off our path (agent forwarding declines, ADR-003).
  • 0.64.1 (2026-10-05) was the POC pin — security-fix-only patch of 0.64.0 (GHSA-4wc5-f2rc-q74m — unbounded memory allocation in the agent protocol server; no API change). The POC findings' evidence and this file's earlier inventory facts were verified against 0.64.1 and carry to 0.65.0 as noted above.
  • 0.64.0 (2026-10-04) broke API: dropped the legacy-ed25519-pkcs8-parser feature (pre-0.43 non-compliant ed25519 keys now hard-error); fixed GHSA-j7h2-66j4-ghjf (server pubkey auth accepted mismatched signature algorithms — e.g. rsa-sha2-512 silently downgraded to rsa-sha1); added Handle::hostkeys_prove and Handle::send_global_request; fixed Limits not being fully enforced.
  • There are older russh families (0.4x–0.5x) with materially different handler shapes in circulation; one rough family-internal integration example exists (alknet main, pinned 0.49) but it is a reading aid only — a rough working example, months stale — and is not decision input for anything here. The pin is chosen against the current published russh, verified at architecture time.

Requirement posture

  • License: Apache-2.0 only. The family crates are MIT OR Apache-2.0. Depending on an Apache-2.0-only dependency from a dual- licensed crate is clean (distribution under the Apache-2.0 branch of our license satisfies it); record this in the pin ADR so the audit trail is explicit.
  • MSRV 1.89, edition 2024. Our crate's rust-version must be ≥ russh's once the dependency lands (currently 1.88 in the manifest — a pending decision, deferred to the pin ADR).
  • Crypto backend: exactly one of aws-lc-rs (default) or ring. The build errors if zero or both are selected. Feature choice is a phase 1 decision (alktls family precedent may pull one way).
  • Other features: flate2 (compression, default), rsa (default; pulls RC-pinned rsa =0.10.0-rc.18 + pkcs1 =0.8.0-rc.4), async-trait, des/dsa ("Danger: insecure" — never enable), serde. The rsa question interacts with the RC-pin question below.
  • RC dependencies in the current pin set: ssh-key =0.7.0-rc.11 (exact-pinned by russh itself via russh::keys::ssh_key re-export), rsa =0.10.0-rc.18, pkcs1 =0.8.0-rc.4, getrandom 0.4. Exact pins mean consumers get the same RCs; but an RC in the tree of a crate we intend to publish is a supply-chain/stability posture point the pin ADR must weigh (wait for ssh-key 0.7.0 stable? accept the RC? which russh family avoids it?).

Server surface (russh::server)

server::Handler trait (async trait, not dyn-compatible)

Required: type Error: From<russh::Error> + Send. Everything else is provided methods; at 0.64.1 the default auth impls are reject (Auth::reject()) for auth_none, auth_password, auth_keyboard_interactive, auth_publickey, and auth_openssh_certificate — only auth_publickey_offered defaults to accept (verified in 0.64.1 source; POC-1 corrected this doc's earlier accept-defaults assumption). Non-auth defaults decline/no-op. The fail-closed discipline is nonetheless ours to implement explicitly as a family contract (the pin is a one-way door; defaults could change on a bump), and POC-1 proves the explicit implementations. Key inventory:

  • Auth: auth_none(user), auth_password(user, password), auth_publickey_offered(user, &PublicKey) (pre-auth probe — cheap rejection hook; key ownership not yet verified), auth_publickey(user, &PublicKey) (called after russh verified the signature and key ownership), auth_openssh_certificate(user, &Certificate), auth_keyboard_interactive(user, submethods, Option<Response>). All return Result<Auth, Self::Error>; russh holds rejection to constant time via Config::auth_rejection_time. auth_succeeded(&mut Session) fires on success.
  • Channel opens (each receives channel: Channel<Msg> + reply: ChannelOpenHandle; dropping the handle without calling reply.accept()/reply.reject() auto-rejects the open — a natural fail-closed default): channel_open_session, channel_open_direct_tcpip(host_to_connect, port_to_connect, originator_address, originator_port), channel_open_forwarded_tcpip(same params), channel_open_direct_streamlocal(socket_path), channel_open_x11(...).
  • Session-channel requests: pty_request(term, col_width, row_height, pix_width, pix_height, &[(Pty, u32)]), shell_request, exec_request(data: &[u8]), subsystem_request(name: &str), env_request(name, value), window_change_request(...), signal(Sig), x11_request, agent_request (returns whether accepted). Success/failure must be signaled explicitly via session.channel_success(channel) / channel_failure(channel).
  • Data plane: data(channel, &[u8]), extended_data(channel, code, &[u8]) (code 1 = stderr), channel_eof, channel_close, window_adjusted, adjust_window (returns next target window).
  • Global: tcpip_forward(address, &mut port) -> bool, cancel_tcpip_forward, streamlocal_forward, cancel_streamlocal_forward (the -R listen flow), and lookup_dh_gex_group.

Dispatch/runtime facts (POC-3, load-bearing for the adapter): the session's event loop invokes handler callbacks inline — a blocking handler suspends all packet routing for the connection (channel data is only routed by that same loop; an inline serve deadlocks against a peer's follow-up bytes, e.g. a receive-pack push). Every serving path must tokio::spawn and return fast. Exit reporting from spawned tasks rides Session::handle() — a cloneable server-side Handle with data / extended_data(channel, ext, data) / eof / close / exit_status_request / exit_signal_request (send exit-status before close). Client-side: Channel::exec() (no request_exec), window_change/signal are fire-and-forget and must be sent before into_stream(), and request-level failures surface as ChannelMsg::Failure/Success on wait(), not as Err from the request call; drain past unexpected messages (continue), a client Handler::check_server_key default that refuses must be accepted explicitly. Details and wire evidence: poc-3-mapping-bakeoff.md §A.4/§D.3.

server::Config knobs (bounded-resources posture)

server_id, methods: MethodSet (advertised auth methods — honest advertisement means this must mirror the feature graph), keys: Vec<PrivateKey> (host keys), certificates: Vec<Certificate>, auth_rejection_time + auth_rejection_time_initial, max_auth_attempts, limits: Limits (bytes/time before rekey), window_size, maximum_packet_size, channel_buffer_size, event_buffer_size, inactivity_timeout, keepalive_interval, keepalive_max, nodelay, preferred: Preferred (algorithm preferences — the place any algorithm restriction would be enforced).

Most family bounded-resource conventions map directly: max concurrent channels is additionally ours (alkcall-side per-identity policy when bridged; in-crate when serving locally), session timeout = inactivity_timeout, auth attempt limit = max_auth_attempts.

Client surface (russh::client)

client::Handler trait

Same shape as server: type Error: From<russh::Error> + Send + Debug; provided methods default sensibly, but check_server_key defaults to reject-all and must be overridden — host-key policy is a phase 1 design point (for the family client: pin the expected server fingerprint at the assembly layer, per the no-trust-on-first-use direction this implies). Inventory: check_server_key (&PublicKeyOrCertificate) → bool; kex_done; the full server-initiated channel-open set (server_channel_open_session / _direct_tcpip / _forwarded_tcpip / _forwarded_streamlocal / _x11 / _agent_forward / _unknown — the client is an acceptor for forwarded channels, i.e. the -R data path) with the same ChannelOpenHandle accept/reject semantics; should_accept_unknown_server_channel; data plane (data, extended_data, exit_status, exit_signal, xon_xoff, window_adjusted, channel_eof/close/...); disconnected; openssh_ext_host_keys_announced (do-not-await-inside warning; proofs go through Handle::hostkeys_prove).

client::Handle<H>

Authentication methods: authenticate_publickey(user, PrivateKeyWithHashAlg), authenticate_password, authenticate_none, authenticate_keyboard_interactive_start/ respond, authenticate_openssh_cert, the *_with signer-injected variants (authenticate_publickey_with(user, PublicKey, hash_alg, &mut Signer) — a signer-injection seam; under alkssh's at-rest storage model (held keys are bearer-token-class secrets decrypted at use — see auth-bridge.md) it is not presumed to be the vault integration shape, but it exists and POC-2 evaluates it against the simple decrypt-and-decode flow; POC-2 verdict: dismissed — no provided impl Signer exists anywhere in 0.64.1 (the doc's "SSH agent" implementation claim is stale), a custom signer must replicate the splice convention (to_sign || string{sig}), and an in-process vault gains nothing over one-time decode at dial — see poc-2-vault-keys.md §4), authenticate_gssapi_with_mic, best_supported_rsa_hash (waits ≤1s for EXT_INFO server-sig-algs).

Channel opening: only the four typed opens exist — channel_open_session, channel_open_x11, channel_open_direct_tcpip(host, port, originator...), channel_open_direct_streamlocal(socket_path) — no generic channel_open(name). On a server reject, the typed open call itself resolves to Err(Error::ChannelOpenFailure(reason)) — no wait()-and-match pattern needed (POC-1 verified with vanilla declines); the session stays usable after declines. Channels expose wait() -> Option<ChannelMsg>, split(), into_stream() -> ChannelStream (AsyncRead + AsyncWrite), make_reader()/make_reader_ext(ext)/make_writer()/make_writer_ext() (tokio duplex adapters — the bridge primitives for every mapping), request_pty/request_shell/exec/request_subsystem/set_env/ window_change/signal/eof/close/exit_status, writable_packet_size().

Global: tcpip_forward(address, port) -> u32 (0 → server picks), cancel_tcpip_forward, streamlocal_forward, disconnect, send_keepalive, rekey_soon, no_more_sessions, send_global_request(name, data, want_reply), hostkeys_prove.

client::Config: window_size, maximum_packet_size, limits, channel_buffer_size, preferred, inactivity_timeout, keepalive_interval/keepalive_max, anonymous, gex, nodelay, client_id. (No client-side auth-attempt limit knob.)

Keys module (russh::keys)

Re-exports: the whole ssh-key crate (ssh_key, pinned =0.7.0-rc.11) plus PrivateKey, PublicKey, Certificate, HashAlg, PublicKeyOrCertificate, PrivateKeyWithHashAlg, and loaders (load_secret_key, decode_secret_key, load_public_key, parse_public_key_base64, load_openssh_certificate, decode_openssh). Fingerprint computation lives in ssh-key (PublicKey::fingerprint, SHA-256 OpenSSH format SHA256:base64) — relevant to auth-bridge.md. known_hosts::check_known_hosts[_path] exists for host-key verification. OpenSSH agent interop (keys::agent) exists but is out of family scope (agent forwarding declines; see mapping-table.md).

Open pin questions for the ADR

  1. Which family/russh line (current stable 0.64.1 is the default candidate) and whether the RC-pinned ssh-key/rsa/pkcs1 tree is acceptable or gates the pin.
  2. Crypto backend (aws-lc-rs vs ring) — align with alktls.
  3. rust-version bump in our manifest (≥ 1.89) — lands with the dependency.
  4. Whether to enable the rsa feature at all (family keys are ed25519-first; disabling rsa drops two RC deps from the tree).
  5. Confirm compatibility with the vanilla clients alkforge needs (ssh
    • git + scp-era behavior) — POC-1's deliverable.

References

  • docs.rs russh 0.64.1: server Handler, client Handler, Configs, Handle, keys module (sections above quote their inventories)
  • russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above)
  • alknet main crates/alknet-core/src/auth/ — rough reference only, explicitly not decision input (0.49-era API; verify nothing against it)

Re-verification at architecture time (2026-10-26)

The "versions re-verified at architecture time" clause, executed. Facts verified today via cargo info / GitHub release notes / a scratch build (/tmp/opencode/alkssh-pin-check, disposable):

Fact Verified value
Current published 0.65.0 (2026-10-25) — see Version landscape above
Backend feature aws-lc-rs (dashed feature name; also russh's own default alongside flate2, rsa)
Scratch build default-features = false, features = ["aws-lc-rs", "rsa"] — clean, ~50 s, no external provisioning (prebuilt aws-lc bindings cover this platform; aws-lc-rs 1.18.2 / aws-lc-sys 0.46.0)
Tree (the pin set) ssh-key =0.7.0-rc.11 (exact), rsa =0.10.0-rc.18, pkcs1 =0.8.0-rc.4, getrandom 0.4.3, rand_core 0.10.1 — same RC set as the POCs' 0.64.1 tree
MSRV 1.89 (both 0.64.1 and 0.65.0)
License Apache-2.0 only

The earlier sections describe the 0.64.1-era surfaces (POC-verified); server::PublicKeyOffer/VerifiedPublicKey and the removed async-trait feature are the only inventory deltas. Wire behavior (evidence base for ADR-001/004/005) is unchanged per the release comparison; the re-verification of the decline matrix against the actual pin is implementation-phase contract-test territory.