- 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
15 KiB
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_offerednow takes aserver::PublicKeyOfferandHandler::auth_publickeyaserver::VerifiedPublicKey(read the key from the.public_keyfield; a new.algorithmfield exposes the client's signature algorithm, allowing server-side reaction to it); theasync-traitdep/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-parserfeature (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); addedHandle::hostkeys_proveandHandle::send_global_request; fixedLimitsnot 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-versionmust be ≥ russh's once the dependency lands (currently1.88in the manifest — a pending decision, deferred to the pin ADR). - Crypto backend: exactly one of
aws-lc-rs(default) orring. 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-pinnedrsa =0.10.0-rc.18+pkcs1 =0.8.0-rc.4),async-trait,des/dsa("Danger: insecure" — never enable),serde. Thersaquestion 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 viarussh::keys::ssh_keyre-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 returnResult<Auth, Self::Error>; russh holds rejection to constant time viaConfig::auth_rejection_time.auth_succeeded(&mut Session)fires on success. - Channel opens (each receives
channel: Channel<Msg>+reply: ChannelOpenHandle; dropping the handle without callingreply.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 viasession.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-Rlisten flow), andlookup_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
- Which family/russh line (current stable 0.64.1 is the default
candidate) and whether the RC-pinned
ssh-key/rsa/pkcs1tree is acceptable or gates the pin. - Crypto backend (
aws-lc-rsvsring) — align with alktls. rust-versionbump in our manifest (≥ 1.89) — lands with the dependency.- Whether to enable the
rsafeature at all (family keys are ed25519-first; disablingrsadrops two RC deps from the tree). - 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, clientHandler,Configs,Handle,keysmodule (sections above quote their inventories) - russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above)
- alknet
maincrates/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.