feat(server): land the SshAdapter mount + russh Config construction (w2-server-mount)
- SshAdapter:🆕 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<ServeConfig> for
w2-auth-bridge / w2-dispatch-decline-core.
- AdapterError (error.rs): Construction + Wire (From<russh::Error>)
— 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.
This commit is contained in:
1 parent
18ef55c60d
commit
b90e89b600
5 files changed
+690
-11
No files matched your search
+476
@@ -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<ServeConfig>,
|
||||
config: Arc<russh::server::Config>,
|
||||
}
|
||||
|
||||
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<Self, AdapterError> {
|
||||
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<R>(&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<PrivateKey>) -> 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<dyn HostKeySource>, 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<dyn HostKeySource> {
|
||||
let keys = pems
|
||||
.iter()
|
||||
.map(|pem| decode_secret_key(pem, None).expect("fixture PEM decodes"))
|
||||
.collect::<Vec<_>>();
|
||||
struct Static {
|
||||
keys: Vec<russh::keys::PrivateKey>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl HostKeySource for Static {
|
||||
async fn load(&self) -> Result<Vec<russh::keys::PrivateKey>, 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<Vec<russh::keys::PrivateKey>, 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<bool, Self::Error> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+76
-1
@@ -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<russh::Error>`, 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<AdapterError> {
|
||||
let mut all: Vec<AdapterError> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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<russh::Error>` 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<ServeConfig>,
|
||||
}
|
||||
|
||||
impl SessionHandler {
|
||||
pub(crate) fn new(serve: Arc<ServeConfig>) -> Self {
|
||||
Self { serve }
|
||||
}
|
||||
}
|
||||
|
||||
impl russh::server::Handler for SessionHandler {
|
||||
type Error = AdapterError;
|
||||
}
|
||||
+4
-1
@@ -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};
|
||||
@@ -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<russh::Error>`); 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
|
||||
`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<ServeConfig>` 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.
|
||||
Reference in new issue
Block a user