--- id: w4-dial name: "Wave 4: the client dial surface (russh client stack + the connection handle)" status: completed depends_on: [review-wave-3] scope: moderate risk: medium impact: phase level: implementation --- ## Description Land the client direction's substrate, the door facing outward (`client.md` §The dial surface): the russh client stack every wave-4 op row composes into, mirroring `w2-server-mount`'s role for the server direction. Everything later in the wave rides the authenticated connection handle this task lands. - **The dial surface**: a `connect`-shaped entry taking the wave-1 [`DialConfig`](the address, the key source, the host-key policy, the `Limits`): the russh client `Config` built from the bounded knobs (keepalive 15 s / 3 default posture per the reverse-runner evidence and `client.md` §Correctness facts; inactivity; window/packet knobs per `overview.md`'s table), the dial target as `DialConfig.address` carries it. If the dial surface needs a `DialConfig` amendment, land + record it here (the `w3-seam-config` course-correction pattern), while the wave-1 shape is expected to hold; decide and record. - **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`] load happens at dial time; locked / absent / decryption-failure / unknown-`key_version` are all **no-dial outcomes** with their distinct `KeySourceError` variants surfaced (never env vars, never plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07 already landed in wave 1, consumed here). - **Host-key policy at `check_server_key`** (OQ-05), the wave-1 [`HostKeyPolicy`] seam consumed at russh's client hook (the verdict type was pinned against this hook shape in `w1-host-key-policy`): the crate default ([`RejectAll`]) connects to nothing; case-1 pins hard-decline on mismatch; case-2 assembly policies decide. The crate is policy-blind, with no accept-all built-in. - **The client Handler skeleton at fail-closed defaults**: the russh `client::Handler` impl the dial constructs: server-initiated channel opens (`server_channel_open_forwarded_tcpip` / `_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined cleanly**, never russh's accept-and-drop default (ADR-003 §4, the client-side symmetry rule; the acceptor registry that serves them is `w4-reverse-listen`'s surface, this task pins the default). - **The authenticated connection handle**, the type everything else rides: the russh `client::Handle` wrapped with the crate's surface (publickey-only authentication from the key source, the family contract; no password/keyboard-interactive paths), the policy and limits carried, the lifecycle primitives' raw material (full cancellation/clean-close shape is `w4-lifecycle`'s). - **Test vehicle**, because the client direction dials *vanilla* servers, so the loopback harness (alkssh server) is not the vehicle: land an in-process russh **server double** (a fake `sshd`: a hand-rolled `russh::server` handler in `tests/common`) as the contract-test vehicle, decide + record its placement and shape. Contract rows: dial success (authenticated), each key-source failure posture (locked/absent/decryption), policy-decline rows (default-reject, pin-mismatch hard decline, case-2 accept), the bounded-config wiring observable, server-initiated open declined cleanly. - **The smoke posture** decided + recorded (an alkssh-client ↔ alkssh-server in-process loopback is the cheap real-stack smoke; a real-`sshd` dial is wave-5 e2e territory, never silently dropped). ## Acceptance Criteria - [x] The dial surface lands over `DialConfig` (+ any amendment decided and recorded); the russh client `Config` carries the bounded knobs (keepalive 15 s / 3 posture, inactivity, window) - [x] Key source decrypt-at-dial; every `KeySourceError` failure posture is a no-dial outcome with its distinct variant surfaced - [x] `check_server_key` calls the injected policy; `RejectAll` connects to nothing; pin-mismatch hard-declines; no accept-all built-in - [x] The client Handler skeleton declines server-initiated opens cleanly (fail-closed default pinned by wire rows) - [x] The authenticated connection handle lands; authentication is publickey-only from the key source - [x] The in-process server double lands in `tests/common`; the contract rows above green; the smoke posture decided + recorded - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 (`-D warnings`), fmt, `cargo doc --no-deps` pass ## References - docs/architecture/client.md §The dial surface, §Correctness facts (the POC-recorded mechanics), §What the crate does *not* include - docs/architecture/decisions/002-russh-pin.md (the client russh surface), docs/architecture/decisions/003-feature-graph.md (§4 the client-side fail-closed acceptor default this skeleton pins), docs/architecture/decisions/005-key-sources.md (decrypt-at-dial) - docs/research/russh.md §Client surface (`client::Handler` inventory, where `check_server_key` defaults to refuse; the `server_channel_open_*` hook set; `client::Config` knobs), docs/research/reverse-runner.md (lessons 3/5, with the keepalives load-bearing, the ephemeral-fleet policy posture) - docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing facts), docs/research/poc-2-vault-keys.md (the dial lifecycle) - src/config.rs (`DialConfig`), src/host_key.rs, src/key_source.rs (the wave-1 seams consumed) ## Notes ### What landed (2026-10-29) - `src/client.rs`: the dial surface, `dial(DialConfig)` over [`DialConfig`], the four-step sequence (key load → bounded russh `Config` → policy at `check_server_key` → single publickey auth), [`SshConnection`] (the connection handle: `limits()` / `address()` / `username()` / `is_closed()` / `close()`, the OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s), the address parse (`host:port` + the bracket form; `DialError::Address` on refusal (dial-time, matching the family's no-construction-validation shape), and the doc-hidden `russh_client_config` mapping (the bounded-config observable). - `src/client_handler.rs`: the client Handler skeleton (the mirror of `handler.rs`'s server-direction handler; a separate module because `w4-op-rows` / `w4-reverse-listen` grow it), with `check_server_key` wired to the injected policy (certificates fingerprint their inner key; the verdict passes through), every `server_channel_open_*` hook overridden explicit-over-inherited with a `ChannelOpenFailure::AdministrativelyProhibited` reject (never russh's accept-and-drop default), `should_accept_unknown_server_channel` the explicit `false`, decline events on the crate vocabulary. - `src/error.rs`: [`DialError`], the dial direction's closed taxonomy (`KeySource` per-variant preserved / `Address` / `HostKeyRejected` / `AuthRejected` / `Wire`), with a manual `From` that upgrades russh's `UnknownKey` (the only producer of which is the `check_server_key` decline path) into `HostKeyRejected`; hence the `Wire` variant carries no `#[from]` (one conversion, one place). - `src/config.rs`: the **DialConfig amendment** (below) + doc updates; `src/observe.rs`: the client-side decline reasons (`no-acceptor`, the skeleton's decline-all default; the registry is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`: the re-exports (`dial` / `SshConnection` / `DialError` public; the handler + config mapping `#[doc(hidden)]` test surface, the `serve.rs` pattern). - `tests/common/sshd_double.rs`: the fake `sshd` (below); `tests/client_dial.rs`: the contract rows incl. the smoke. ### Decided + recorded 1. **The DialConfig amendment (`username`), the task's sanctioned course-correction, the only one.** The wave-1 shape carried everything `client.md` §The dial surface names, but the USERAUTH_REQUEST structurally requires a user string (`authenticate_publickey(user, key)`) and the remote end evaluates it, and no dial can exist without one. Decided with the `w3-seam-config` pattern: `username: String` is a required `DialConfig::new` parameter (no crate default, since a default account would be deployment policy smuggled into the crate; the empty string is accepted as data and is the remote end's to refuse). It is *not identity*, as the identity model stays fingerprint-only (ADR-004 decision 5, mirrored client-side), it is never logged (the event vocabulary carries no username), and it rides [`SshConnection`] as carried dial data. Wave-1 records amended (the config module + field docs). 2. **The server double's placement/shape** (`tests/common/sshd_double.rs`): a plain russh server (no alkssh in its serving path, hence vanilla-parity) on a **real loopback TCP listener** at `127.0.0.1:0` (a dial target is an address the surface resolves and connects to; the duplex-pair trick is the server-direction harness's, and a dial has no `&mut Handle` slot to attach one to). One dial per instance (doubles are cheap); the handler resolves fingerprints against an injected accept set (the vanilla-`authorized_keys` stand-in) with every other auth hook an explicit reject; the `auth_succeeded` hook hands the row the per-connection `russh::server::Handle` through a one-shot carrier, which is the initiator for the server-initiated-open decline rows (the double calls `channel_open_*` at the client and observes each as `Err(ChannelOpenFailure(AdministrativelyProhibited))`); a per-session outcome channel is the clean-close rows' observable; an optional client-session acceptor stub exists for later rows (`next_opened_session`). 3. **The smoke posture**: an alkssh-client ↔ alkssh-server in-process loopback over a real TCP socket, landed as the row `smoke_alkssh_client_dials_the_alkssh_server_direction` (`tests/client_dial.rs`, with both russh stacks, the full auth bridge server-side, the full custody + policy chain client-side). A real-`sshd` dial is wave-5 e2e territory (`docs/plans/ implementation.md` §Wave 5); nothing is silently dropped. 4. **The decline-class mapping**: russh surfaces a policy decline as its `UnknownKey` wire error; since the client's `check_server_key` hook is the only producer of that class, the manual `From` makes it the semantic [`DialError::HostKeyRejected`], while every other russh error stays [`DialError::Wire`]. 5. **The skeleton's unknown-type gate** (`should_accept_unknown_ server_channel` → explicit `false`) is code-pinned, not row-pinned: russh's server `Handle` exposes no initiator for an unknown channel type, and russh's `ChannelId` is deliberately unconstructible outside the library, so no wire row can reach the hook. The six reachable hooks (session / direct-tcpip / forwarded-tcpip / forwarded-streamlocal / x11 / agent) are wire-row-pinned; the `_unknown` open hook is a defensive reject behind the gate. 6. **Auth posture**: exactly one `authenticate_publickey` attempt (ed25519-only, ADR-002 decision 4, `None` being the whole hash-algorithm posture); a refusal closes the attempt cleanly (`Disconnect::ByApplication`) and surfaces [`DialError::AuthRejected`]. russh's other auth methods (password / kbi / cert / gss / signer-injection, with the signer seam dismissed by ADR-005 decision 8) are never reached from [`SshConnection`]. 7. **Bounded posture**: the knob mapping keeps the crate's documented postures (`keepalive` 15 s / 3, the wall clock always `Some`, window/packet/buffer verbatim) and leaves russh's own default postures untouched (rekey `Limits`, preferred algorithms, `client_id`, `nodelay`. No client-side auth-attempt knob exists at this pin and the single-attempt contract makes none needed). ### Verification `cargo test` (default) and `cargo test --all-features` (and per-feature `--features {session,git,tunnel}`) all green, with 14 test binaries each, the new `tests/client_dial.rs` carrying 10 rows; `cargo clippy --all-features --all-targets` and `cargo clippy --all-targets` both `-D warnings` clean; `cargo fmt --check` clean; `cargo doc --no-deps` warning-free. ## Summary The client direction's substrate landed: `dial(config) -> Result` runs the fail-closed four-step sequence (decrypt-at-dial key load with every no-dial posture distinct; the bounded russh client `Config` from `Limits`; the injected host-key policy at `check_server_key`, where `RejectAll` connects to nothing; a single publickey-only auth attempt) and returns the authenticated connection handle everything later in the wave rides. One sanctioned `DialConfig` amendment: the required `username` field (the USERAUTH_REQUEST's user string; no crate default, never identity, never logged). The fail-closed client Handler skeleton declines every server-initiated open on the wire (the fake-`sshd` double in `tests/common` pins six hook rows + decline events), and the alkssh-client ↔ alkssh-server loopback smoke is landed as a row. The `w4-op-rows` chain grows this skeleton in place.