Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
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.
2026-11-04 20:28:00 +00:00

280 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.