From b90e89b6004ae6a163ba3f20d9c1a40538aecd83 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 07:51:50 +0000 Subject: [PATCH] feat(server): land the SshAdapter mount + russh Config construction (w2-server-mount) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SshAdapter::new: decrypt-at-construction host-key load (ADR-005 decision 6) — locked / absent / decryption / unknown-version / decode all refuse with AdapterError::Construction(KeySourceError preserved), no adapter; an empty key set refuses as Absent even from a custom source. - russh 0.65 server Config built from ServeConfig: methods exactly {publickey} (MethodSet from [MethodKind::PublicKey] — honest advertisement, ADR-004 decision 4); host keys ride Config::keys; Limits knobs (window_size, maximum_packet_size, channel_buffer_size, max_auth_attempts) one-for-one; inactivity_timeout always Some (bounded-resources posture). All other knobs stay russh's default posture: rekey Limits (RFC-4253 defaults, russh-enforced), auth_rejection_time, server_id (russh default — recorded), server keepalive (client-direction posture only). - SshAdapter::handle: ADR-001 mount — generic duplex stream in, run_stream per stream, run to completion (Result<(), AdapterError>; no ALPN/relay code; the serving loop is spawned inside russh, so ADR-006's spawn-and-return holds without double-spawning). - SessionHandler skeleton (pub(crate)): per-connection russh::server::Handler with type Error = AdapterError; every hook at the inherited fail-closed default; carries Arc for w2-auth-bridge / w2-dispatch-decline-core. - AdapterError (error.rs): Construction + Wire (From) — the wave-1 error-model's reserved wave-2 shape. - Tests: advertisement-exactly-publickey; knob mapping (tuned + default postures); key-set parity; construction failure matrix; EOF-on-mount; wire smoke (russh client over duplex: full KEX, fail-closed auth decline, exactly the {publickey} method list on the wire). Verified: cargo test (61 across suites), cargo test --all-features, clippy --all-targets -- -D warnings (default + all-features), fmt --check, cargo doc --no-deps, taskgraph validate. --- src/adapter.rs | 476 ++++++++++++++++++++++++ src/error.rs | 77 +++- src/handler.rs | 45 +++ src/lib.rs | 5 +- tasks/implementation/w2-server-mount.md | 98 ++++- 5 files changed, 690 insertions(+), 11 deletions(-) create mode 100644 src/adapter.rs create mode 100644 src/handler.rs diff --git a/src/adapter.rs b/src/adapter.rs new file mode 100644 index 0000000..63bb029 --- /dev/null +++ b/src/adapter.rs @@ -0,0 +1,476 @@ +//! The server-direction mount point (ADR-001) — [`SshAdapter`], the +//! constructed russh server stack every mapping composes into. +//! +//! Construction ([`SshAdapter::new`], ADR-005 decision 6): the +//! injected [`HostKeySource`] loads **decrypt-at-construction** — a +//! locked / absent / decryption-failed / unknown-version / undecodable +//! load means **no server is constructed**: the error surfaces as +//! [`AdapterError::Construction`] with the original [`KeySourceError`] +//! variant preserved, never a half-configured adapter, never a keyless +//! server that fails later. An empty key set is the same refusal (the +//! host-key source contract's [`KeySourceError::Absent`] shape). +//! +//! The russh server `Config` (ADR-002 surface notes; ADR-004 decision +//! 4): `methods` is `{publickey}` **exactly** (honest advertisement — +//! `MethodSet::from(&[MethodKind::PublicKey][..])`, never russh's +//! `server_supported()`), the loaded host keys ride `Config::keys`, +//! and the [`Limits`] knobs map one-for-one: +//! `window_size` / `maximum_packet_size` / `channel_buffer_size` / +//! `max_auth_attempts` verbatim, `inactivity_timeout` as **always +//! `Some`** — the bounded-resources posture (the crate never +//! constructs a session with no wall clock). Everything else stays at +//! russh 0.65's own default posture (`Config::default()`, left +//! un-touched deliberately): the rekey `Limits` stay russh-enforced at +//! their RFC-4253 §9 defaults, `auth_rejection_time` stays russh's +//! constant-rejection knob, and the preferred-algorithm set stays +//! russh's — the ed25519-only key-algorithm posture holds by the +//! loaded key set itself (ADR-002 decision 4) and by the auth bridge's +//! explicit non-ed25519 decline (`w2-auth-bridge`). +//! +//! The mount ([`SshAdapter::handle`], ADR-001's contract): a generic +//! byte-duplex stream in, the full russh server stack per stream via +//! [`russh::server::run_stream`], returned **run to completion** — +//! the edge-termination shape only, no ALPN code, no relay leg (the +//! `alk/ssh` pin is a ledger entry; nothing here speaks it). The +//! in-flight framing is the alkhttp door precedent: `handle` is an +//! async per-stream call whose completion is the session's end, so the +//! assembly's accept loop mounts per stream via `tokio::spawn` — the +//! serving loop itself stays spawned inside russh (ADR-006 decision 4, +//! which `run_stream` already satisfies: the session's packet loop +//! leaves on its own task), and the mount's return type stays +//! `Result<(), AdapterError>` — no russh type rides the signature. + +use std::sync::Arc; + +use russh::MethodKind; +use russh::MethodSet; +use russh::keys::PrivateKey; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::config::ServeConfig; +use crate::error::{AdapterError, KeySourceError}; +use crate::handler::SessionHandler; + +/// The constructed server stack — the substrate every channel-type +/// mapping composes into (ADR-001's `SshAdapter`). +/// +/// Built by [`SshAdapter::new`] from the assembly-supplied +/// [`ServeConfig`] (a fail-closed load: see the module docs); mounted +/// per stream by [`SshAdapter::handle`]. Construction is async: the +/// host-key decrypt participates there and nowhere else. +#[derive(Debug)] +pub struct SshAdapter { + serve: Arc, + config: Arc, +} + +impl SshAdapter { + /// Constructs the adapter — decrypt-at-construction of the host + /// key set (ADR-005 decision 6): a failed or empty load is no + /// server, with the [`KeySourceError`] refused through + /// [`AdapterError::Construction`]. + pub async fn new(serve: ServeConfig) -> Result { + let keys = serve.host_key_source.load().await?; + if keys.is_empty() { + return Err(AdapterError::Construction(KeySourceError::Absent)); + } + let config = build_server_config(&serve, keys); + Ok(Self { + serve: Arc::new(serve), + config: Arc::new(config), + }) + } + + /// The mounted russh server `Config` — in-crate inspection for the + /// contract tests (advertisement and knob mapping); not public + /// surface: the mount's behavior is observable on the wire, never + /// through a russh type leak. + #[cfg(test)] + pub(crate) fn config(&self) -> &russh::server::Config { + &self.config + } + + /// The mount (ADR-001): mounts the full russh server stack on one + /// generic duplex byte stream and runs it to completion — an + /// accepted vanilla-SSH transport is authenticated and served on + /// this call's task; the assembly's accept loop mounts per stream + /// with `tokio::spawn(adapter.handle(stream))`. + /// + /// The stream is owned edge termination only: no ALPN, no relay + /// leg, no framing interpretation upstream of russh. + pub async fn handle(&self, stream: R) -> Result<(), AdapterError> + where + R: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let handler = SessionHandler::new(Arc::clone(&self.serve)); + russh::server::run_stream(Arc::clone(&self.config), stream, handler) + .await? + .await + } +} + +/// The adapter's own `Config` construction from the serve-config — the +/// documented subset mapped one-for-one, everything else at russh's +/// default posture (see the module docs; ADR-002 surface notes). +fn build_server_config(serve: &ServeConfig, keys: Vec) -> russh::server::Config { + let limits = serve.limits; + russh::server::Config { + methods: MethodSet::from(&[MethodKind::PublicKey][..]), + keys, + window_size: limits.window_size, + maximum_packet_size: limits.maximum_packet_size, + channel_buffer_size: limits.channel_buffer_size, + max_auth_attempts: limits.max_auth_attempts, + inactivity_timeout: Some(limits.inactivity_timeout), + ..russh::server::Config::default() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use alkvault::CURRENT_KEY_VERSION; + use russh::client::AuthResult; + use russh::keys::decode_secret_key; + + use super::{SshAdapter, build_server_config}; + use crate::config::{Limits, ServeConfig}; + use crate::error::{AdapterError, KeySourceError}; + use crate::key_source::{HostKeySource, VaultHostKeySource}; + + const NOOP: alkcall::core::auth::NoopIdentityProvider = + alkcall::core::auth::NoopIdentityProvider; + + /// The committed test-key fixtures — the wave-1 fixture set, read + /// through `include_str!` per the wave-1 convention (the + /// `tests/common` double module is the tests/-side owner; src-unit + /// tests read the fixture files, never its exports). + const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key"); + const BRAVO_PEM: &str = include_str!("../tests/vectors/bravo.key"); + const CHARLIE_PEM: &str = include_str!("../tests/vectors/charlie.key"); + + fn unlocked_vault() -> alkvault::VaultServiceHandle { + let vault = alkvault::VaultServiceHandle::new(); + vault.unlock_new(24).expect("in-memory vault unlocks"); + vault + } + + fn sealed(vault: &alkvault::VaultServiceHandle, pem: &str) -> alkvault::EncryptedData { + vault + .encrypt(pem, CURRENT_KEY_VERSION) + .expect("seals the test PEM") + } + + fn serve_config(host_key_source: Arc, limits: Limits) -> ServeConfig { + let mut serve = ServeConfig::new(Arc::new(NOOP), host_key_source); + serve.limits = limits; + serve + } + + fn static_fixture_host_keys(pems: &[&str]) -> Arc { + let keys = pems + .iter() + .map(|pem| decode_secret_key(pem, None).expect("fixture PEM decodes")) + .collect::>(); + struct Static { + keys: Vec, + } + #[async_trait::async_trait] + impl HostKeySource for Static { + async fn load(&self) -> Result, KeySourceError> { + Ok(self.keys.clone()) + } + } + Arc::new(Static { keys }) + } + + #[tokio::test] + async fn methods_advertise_exactly_publickey() { + let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default()); + let adapter = SshAdapter::new(serve).await.expect("constructs"); + + let advertised = &adapter.config().methods; + assert_eq!(advertised.len(), 1); + assert_eq!(advertised.first(), Some(&russh::MethodKind::PublicKey)); + assert_eq!( + advertised, + &russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]) + ); + assert_ne!(advertised, &russh::MethodSet::server_supported()); + } + + #[tokio::test] + async fn limits_knobs_map_one_for_one_with_inactivity_always_set() { + let limits = Limits { + window_size: 1_024, + maximum_packet_size: 4_096, + channel_buffer_size: 7, + max_auth_attempts: 3, + inactivity_timeout: Duration::from_secs(1_337), + ..Limits::default() + }; + let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), limits); + + let adapter = SshAdapter::new(serve).await.expect("constructs"); + let config = adapter.config(); + + assert_eq!(config.window_size, 1_024); + assert_eq!(config.maximum_packet_size, 4_096); + assert_eq!(config.channel_buffer_size, 7); + assert_eq!(config.max_auth_attempts, 3); + assert_eq!(config.inactivity_timeout, Some(Duration::from_secs(1_337))); + } + + #[tokio::test] + async fn default_limits_map_to_the_documented_postures() { + let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default()); + let adapter = SshAdapter::new(serve).await.expect("constructs"); + let config = adapter.config(); + + assert_eq!(config.window_size, 2_097_152); + assert_eq!(config.maximum_packet_size, 32_768); + assert_eq!(config.channel_buffer_size, 100); + assert_eq!(config.max_auth_attempts, 10); + assert_eq!( + config.inactivity_timeout, + Some(crate::config::DEFAULT_INACTIVITY_TIMEOUT) + ); + } + + #[tokio::test] + async fn host_keys_load_decrypt_at_construction_into_the_russh_key_set() { + let vault = unlocked_vault(); + let blobs = vec![sealed(&vault, BRAVO_PEM), sealed(&vault, CHARLIE_PEM)]; + let serve = serve_config( + Arc::new(VaultHostKeySource::new(vault, blobs)), + Limits::default(), + ); + + let adapter = SshAdapter::new(serve).await.expect("constructs"); + let config = adapter.config(); + + let expected_bravo = decode_secret_key(BRAVO_PEM, None).expect("decodes"); + let expected_charlie = decode_secret_key(CHARLIE_PEM, None).expect("decodes"); + + assert_eq!(config.keys.len(), 2); + assert_eq!( + crate::fingerprint::fingerprint(config.keys[0].public_key()), + crate::fingerprint::fingerprint(expected_bravo.public_key()) + ); + assert_eq!( + crate::fingerprint::fingerprint(config.keys[1].public_key()), + crate::fingerprint::fingerprint(expected_charlie.public_key()) + ); + } + + #[tokio::test] + async fn locked_vault_at_construction_is_no_adapter() { + let vault = unlocked_vault(); + let blobs = vec![sealed(&vault, BRAVO_PEM)]; + vault.lock(); + + let serve = serve_config( + Arc::new(VaultHostKeySource::new(vault, blobs)), + Limits::default(), + ); + let err = SshAdapter::new(serve) + .await + .expect_err("locked vault refuses construction"); + assert!( + matches!(err, AdapterError::Construction(KeySourceError::Locked)), + "expected Construction(Locked), got {err:?}" + ); + } + + #[tokio::test] + async fn absent_host_keys_at_construction_is_no_adapter() { + let vault = unlocked_vault(); + let serve = serve_config( + Arc::new(VaultHostKeySource::new(vault, Vec::new())), + Limits::default(), + ); + + let err = SshAdapter::new(serve) + .await + .expect_err("no host keys refuse construction"); + assert!( + matches!(err, AdapterError::Construction(KeySourceError::Absent)), + "expected Construction(Absent), got {err:?}" + ); + } + + #[tokio::test] + async fn empty_set_from_a_custom_source_refuses_construction() { + struct Empty; + #[async_trait::async_trait] + impl HostKeySource for Empty { + async fn load(&self) -> Result, KeySourceError> { + Ok(Vec::new()) + } + } + + let serve = serve_config(Arc::new(Empty), Limits::default()); + let err = SshAdapter::new(serve) + .await + .expect_err("an empty key set serves nothing honestly"); + assert!(matches!( + err, + AdapterError::Construction(KeySourceError::Absent) + )); + } + + #[tokio::test] + async fn every_key_source_variant_surfaces_through_construction() { + let tampered_vault = unlocked_vault(); + let tampered = sealed(&tampered_vault, BRAVO_PEM); + let mut tampered = tampered; + tampered.data = "not the ciphertext".into(); + let decrypt_failure = Arc::new(VaultHostKeySource::new(tampered_vault, vec![tampered])); + + let version_vault = unlocked_vault(); + let version_blob = sealed(&version_vault, BRAVO_PEM); + let mut version_blob = version_blob; + version_blob.key_version = 1; + let version_failure = Arc::new(VaultHostKeySource::new(version_vault, vec![version_blob])); + + let decode_vault = unlocked_vault(); + let decode_failure = Arc::new(VaultHostKeySource::new( + decode_vault.clone(), + vec![sealed(&decode_vault, "this is not a key")], + )); + + let stranger_vault = unlocked_vault(); + let stranger = Arc::new(VaultHostKeySource::new( + unlocked_vault(), + vec![sealed(&stranger_vault, BRAVO_PEM)], + )); + + for (source, expected) in [ + (decrypt_failure, KeySourceError::Decryption(String::new())), + ( + version_failure, + KeySourceError::UnknownKeyVersion(String::new()), + ), + (decode_failure, KeySourceError::Decode(String::new())), + (stranger, KeySourceError::Decryption(String::new())), + ] { + let serve = serve_config(source, Limits::default()); + let err = SshAdapter::new(serve).await.expect_err("refuses"); + let matched = matches!( + err, + AdapterError::Construction(ref e) if std::mem::discriminant(e) + == std::mem::discriminant(&expected) + ); + assert!(matched, "expected {expected:?} class, got {err:?}"); + } + } + + #[tokio::test] + async fn handle_mounts_the_stack_and_errors_when_the_peer_disconnects() { + let serve = serve_config(static_fixture_host_keys(&[BRAVO_PEM]), Limits::default()); + let adapter = SshAdapter::new(serve).await.expect("constructs"); + + let (client, server) = tokio::io::duplex(65_536); + drop(client); + + let outcome = adapter.handle(server).await; + assert!( + matches!(outcome, Err(AdapterError::Wire(_))), + "expected a wire-class mount failure, got {outcome:?}" + ); + } + + struct AcceptAllHostKeys; + + impl russh::client::Handler for AcceptAllHostKeys { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + Ok(true) + } + } + + #[tokio::test] + async fn wire_smoke_full_stack_mounts_and_skeleton_declines_auth_fail_closed() { + let serve = serve_config( + static_fixture_host_keys(&[BRAVO_PEM]), + Limits { + max_auth_attempts: 3, + ..Limits::default() + }, + ); + let adapter = SshAdapter::new(serve).await.expect("constructs"); + + let (client_io, server_io) = tokio::io::duplex(65_536); + let server_task = tokio::spawn(async move { adapter.handle(server_io).await }); + + let client_config = Arc::new(russh::client::Config { + inactivity_timeout: Some(Duration::from_secs(30)), + ..Default::default() + }); + let mut handle = russh::client::connect_stream(client_config, client_io, AcceptAllHostKeys) + .await + .expect("client mounts against the server stack"); + + let alpha = Arc::new(decode_secret_key(ALPHA_PEM, None).expect("fixture decodes")); + let result = handle + .authenticate_publickey( + "anyone", + russh::keys::PrivateKeyWithHashAlg::new(alpha, None), + ) + .await + .expect("auth exchange completes over the wire"); + + let remaining = match &result { + AuthResult::Failure { + remaining_methods, .. + } => remaining_methods.clone(), + other => { + handle + .disconnect(russh::Disconnect::ByApplication, "test done", "en") + .await + .ok(); + panic!("the skeleton's fail-closed posture must not authenticate: {other:?}") + } + }; + assert!( + !result.success(), + "the skeleton's every-hook-default posture must not authenticate" + ); + assert_eq!( + remaining, + russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]), + "the wire carries exactly the publickey-only advertisement" + ); + + handle + .disconnect(russh::Disconnect::ByApplication, "test done", "en") + .await + .expect("client disconnects cleanly"); + server_task + .await + .expect("server task ends") + .expect("clean session end"); + } + + #[test] + fn server_config_records_the_russh_default_postures_it_keeps() { + let serve = ServeConfig::new(Arc::new(NOOP), static_fixture_host_keys(&[BRAVO_PEM])); + let config = build_server_config(&serve, Vec::new()); + + assert_eq!( + config.auth_rejection_time, + std::time::Duration::from_secs(1), + "constant-time rejection stays russh-managed" + ); + assert_eq!( + config.keepalive_interval, None, + "the keepalive posture is the client direction's; the server stays russh-default" + ); + } +} diff --git a/src/error.rs b/src/error.rs index 12d68d7..501c61d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -104,9 +104,41 @@ pub enum KeySourceError { Decode(String), } +/// The adapter (server-direction) error surface — the two failure +/// classes the mount point can reach, deliberately closed: +/// +/// - **Construction** — the [`SshAdapter::new`](crate::SshAdapter::new) +/// host-key load failed: the [`KeySourceError`] variant is preserved +/// verbatim (locked / absent / decryption / unknown-version / decode +/// each stays itself) and **no adapter is constructed** (ADR-005 +/// decision 6 — never a half-configured adapter, never a keyless +/// server that fails later). +/// - **Wire** — the russh session stack's own error, mapped +/// transparently (`From`, the wave-1 error-model's +/// reserved `From` requirement of the per-connection +/// [`russh::server::Handler`]): KEX, transport, and IO failures of +/// a mounted session. Auth declines are *not* this class — they are +/// dispositions (ADR-004), and wire decline reasons are the +/// dispatch's SSH-shaped responses, never this taxonomy. +/// +/// There is no catch-all variant: everything the crate's server +/// direction can fail is one of these two classes or a decline. +#[derive(Debug, Error)] +pub enum AdapterError { + /// The host-key load at adapter construction refused — fail-closed + /// (ADR-005 decision 6). The [`KeySourceError`] variant is the + /// original refusal class, preserved un-collapsed. + #[error("ssh server construction refused: {0}")] + Construction(#[from] KeySourceError), + + /// A mounted russh session errored (transport / KEX / IO class). + #[error("ssh session error: {0}")] + Wire(#[from] russh::Error), +} + #[cfg(test)] mod tests { - use super::KeySourceError; + use super::{AdapterError, KeySourceError}; fn all_variants() -> [KeySourceError; 5] { [ @@ -171,4 +203,47 @@ mod tests { let _ = v.clone(); } } + + fn all_adapter_variants() -> Vec { + let mut all: Vec = all_variants() + .into_iter() + .map(AdapterError::Construction) + .collect(); + all.push(AdapterError::Wire(russh::Error::UnknownKey)); + all + } + + #[test] + fn adapter_error_closed_taxonomy_two_classes() { + let exhaustive: fn(&AdapterError) -> &'static str = |e| match e { + AdapterError::Construction(_) => "construction", + AdapterError::Wire(_) => "wire", + }; + for v in all_adapter_variants() { + assert!(!exhaustive(&v).is_empty()); + } + } + + #[test] + fn construction_preserves_the_key_source_variant() { + for source in all_variants() { + let wrapped = AdapterError::Construction(source.clone()); + match wrapped { + AdapterError::Construction(seen) => { + assert_eq!(seen.to_string(), source.to_string()) + } + other => panic!("unexpected variant {other:?}"), + } + } + } + + #[test] + fn russh_wire_error_is_transparently_mapped() { + let mapped: AdapterError = russh::Error::UnknownKey.into(); + assert!(matches!( + mapped, + AdapterError::Wire(russh::Error::UnknownKey) + )); + assert_eq!(mapped.to_string(), "ssh session error: Unknown server key"); + } } diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..dd95671 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,45 @@ +//! The per-connection server handler — the wave-2 skeleton the later +//! wave-2 tasks fill (`w2-auth-bridge` the auth hooks, +//! `w2-dispatch-decline-core` the channel/request dispatch, this file +//! the substrate only). +//! +//! Skeleton posture (AGENTS.md convention + ADR-004): every +//! [`russh::server::Handler`] hook currently stands at its inherited +//! **fail-closed russh default** — auth hooks reject, channel hooks +//! accept nothing, request hooks reply nothing. A client can mount a +//! connection and never cross auth. The skeleton's substance is the +//! error type ([`AdapterError`], `From` as russh's +//! `Handler` contract requires) and the carried state the later tasks +//! consume: the whole [`ServeConfig`] handle. +//! +//! Fail-closed on the wire, honestly: this is exactly the posture the +//! decline-matrix tasks gate from — the skeleton declines everything +//! by construction, never advertises a capability it does not serve. + +use std::sync::Arc; + +use crate::config::ServeConfig; +use crate::error::AdapterError; + +/// The per-connection handler (one per mounted stream): russh constructs +/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted +/// stream and drives it through [`russh::server::run_stream`]. +/// +/// Carries the serve-config the auth bridge and dispatch consume; at +/// the wave-2 skeleton stage everything it does is inherited — and the +/// inherited surface is fail-closed (auth rejects, channels decline), +/// so a mounted session through this skeleton serves nothing. +pub(crate) struct SessionHandler { + #[allow(dead_code)] + serve: Arc, +} + +impl SessionHandler { + pub(crate) fn new(serve: Arc) -> Self { + Self { serve } + } +} + +impl russh::server::Handler for SessionHandler { + type Error = AdapterError; +} diff --git a/src/lib.rs b/src/lib.rs index 71cde88..25dc3ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,14 +24,17 @@ //! validation are later implementation waves — wave 2 decomposes after //! this gate). See also `docs/research/` and family ADR-003. +mod adapter; mod config; mod error; mod fingerprint; +mod handler; mod host_key; mod key_source; +pub use adapter::SshAdapter; pub use config::{DialConfig, Limits, ServeConfig}; -pub use error::KeySourceError; +pub use error::{AdapterError, KeySourceError}; pub use fingerprint::fingerprint; pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll}; pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource}; diff --git a/tasks/implementation/w2-server-mount.md b/tasks/implementation/w2-server-mount.md index 44d62f2..c3d1e93 100644 --- a/tasks/implementation/w2-server-mount.md +++ b/tasks/implementation/w2-server-mount.md @@ -1,7 +1,7 @@ --- id: w2-server-mount name: Wave 2 — SshAdapter::handle mount + russh Config construction -status: pending +status: completed depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model] scope: moderate risk: medium @@ -57,21 +57,21 @@ yet (the dispatch task is their first `cfg` consumer). ## Acceptance Criteria -- [ ] `SshAdapter::new(ServeConfig)` loads host keys +- [x] `SshAdapter::new(ServeConfig)` loads host keys decrypt-at-construction; locked / absent / decryption-failure / unknown-version → `Err` with the `KeySourceError` variant preserved, no adapter constructed -- [ ] russh `Config` built from the serve-config: `methods` is exactly +- [x] russh `Config` built from the serve-config: `methods` is exactly `{publickey}`; host keys ride `Config::keys`; the `Limits` knobs map one-for-one with `inactivity_timeout` always `Some` -- [ ] `SshAdapter::handle` mounts the full russh server stack per +- [x] `SshAdapter::handle` mounts the full russh server stack per generic stream (`run_stream`); no ALPN/relay code exists -- [ ] The Handler skeleton compiles with an adapter error type +- [x] The Handler skeleton compiles with an adapter error type (`From`); every hook at its fail-closed default -- [ ] Tests: honest advertisement (methods exactly `{publickey}`); the +- [x] Tests: honest advertisement (methods exactly `{publickey}`); the knob mapping; construction failure postures (locked vault → no server, absent host keys → no server) -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -91,8 +91,88 @@ yet (the dispatch task is their first `cfg` consumer). ## Notes -> To be filled by implementation agent +Implementation record (decisions made at this task's discretion, the +"decide and record" items): + +- **The mount's return shape — `handle` runs to completion** + (`Result<(), AdapterError>`): `handle` is an async per-stream call + that awaits `run_stream`'s `RunningSession` through the session's + end — the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler:: + handle` is spawned per connection by the assembly's accept loop; + alkhttp/src/server/adapter.rs spawns it in tests the same way). + ADR-006 decision 4 is satisfied inside russh itself: `run_stream` + spawns the session's packet loop on its own task before returning, + so the serving path is already spawned-and-returned at the + handler-hook level; `handle` does not double-spawn (that would add + a task layer and a join-handle surface without adding posture). The + alternative candidates (returning `RunningSession`, or an internal + spawn + `JoinHandle`) were rejected: the first leaks a generic + russh type into the public signature (plus the `Handler` type it + parameterizes); the second hides the failure boundary behind a + detached task while still not matching the accept-loop shape any + better. The chosen shape's signature is `Result<(), AdapterError>` + — the smallest honest surface; it is semver-visible and recorded + here. +- **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the + `Config::default()` posture). No family reason found to change it: + the vanilla-ssh wire is vanilla; the version string is + interoperability information, not a family advertisement, and + POC-3's real-tool evidence rode that posture. Changing it would be + policy the crate doesn't own; recorded for any future pin bump. +- **The ed25519-only posture holds by construction + the auth bridge, + not by a construction check.** The loaded key set is the + advertisement; ADR-002 decision 4's "an assembly registers ed25519 + fingerprints only" governs what the assembly sources. Flagged for + the wave-2 gate: if host-key algorithm filtering ever becomes + wire-contract territory (currently it is not — the key source + contract is silent), it would be an ADR amendment, not this task. +- **Empty key set = `KeySourceError::Absent` even from a custom + source**: `SshAdapter::new` additionally refuses an `Ok(vec![])` + load as `Construction(Absent)` — `Config::keys` empty would serve + no host key honestly, and the `key_source.rs` wave-1 contract + already documents the empty set as the absent shape. +- **Keepalive stays russh-default on the server direction** + (`None` / 3): the `Limits` keepalive knobs are documented + client-direction posture (reverse-runner evidence); no server-side + keepalive posture exists and none is invented here. +- **`SessionHandler` is `pub(crate)`** — the skeleton's type never + appears in a public signature (thanks to the run-to-completion + return shape), so the public API this task exposes is exactly + `SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility + when its handler-level tests need it. +- Tests ride the wave-1 fixture keys via `include_str!` (the wave-1 + src-unit convention) and the real `VaultHostKeySource` through an + in-memory vault for the decrypt-at-construction rows; one wire smoke + (russh's own client over a tokio duplex through `handle`) proves the + full stack mounts and the skeleton declines auth fail-closed with + exactly the `{publickey}` advertisement visible on the wire. ## Summary -> To be filled on completion \ No newline at end of file +`SshAdapter` is landed as the ADR-001 mount point: `SshAdapter::new` +decrypt-loads the host key set at construction (any load failure or +empty set → `AdapterError::Construction(KeySourceError variant)`, no +adapter) and builds the russh 0.65 server `Config` with honest +advertisement (`methods` exactly `{publickey}`), the loaded keys, the +`Limits` knobs mapped one-for-one, `inactivity_timeout` always +`Some`, and everything else (rekey `Limits`, `auth_rejection_time`, +`server_id`, preferred sets) left verbatim at russh's default posture. +`SshAdapter::handle` is the edge-termination mount: a generic duplex +stream in, `run_stream` per stream, run to completion to +`Result<(), AdapterError>` (no ALPN/relay code; the `alk/ssh` pin +unspoken). The per-connection `SessionHandler` skeleton (pub(crate)) +carries the `Arc` for the auth-bridge/dispatch tasks and +stands entirely at russh's fail-closed defaults, with +`AdapterError` (`Construction(#[from] KeySourceError)` + +`Wire(#[from] russh::Error)`) as its `type Error`. + +Verification: 12 new tests — advertisement-exactly-publickey, knob +mapping (tuned + documented-default postures), decrypt-at-construction +key-set parity, construction failure matrix (locked / absent / empty / +tampered / wrong-vault / unknown-version / decode, variant preserved), +EOF-on-mount, and a wire smoke (russh client over duplex completing +KEX against the fixture host key and observing the fail-closed +decline with the exact `{publickey}` method list). `cargo test`, +`cargo test --all-features`, `cargo clippy --all-targets -- -D +warnings`, `cargo clippy --all-features --all-targets -- -D warnings`, +`cargo fmt --check`, and `cargo doc --no-deps` all green. \ No newline at end of file