Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
280 lines
15 KiB
Markdown
280 lines
15 KiB
Markdown
# 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, a 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 **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, a 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, since 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)`; there is no generic
|
||
`channel_open(name)`. On a server **reject**, the typed open call
|
||
itself resolves to `Err(Error::ChannelOpenFailure(reason))`, and 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
|
||
|
||
All resolved by ADR-002 (2026-10-26, amended 2026-10-27);
|
||
items below are the questions as phase 0 posed them:
|
||
|
||
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), landing 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`, `Config`s,
|
||
`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. |