feat(auth): land the ADR-004 dispositional auth bridge (w2-auth-bridge)
- auth_publickey: ed25519-only gate → single fingerprint path → resolve_from_fingerprint → accept-with-identity (stored per- connection, read by wave 3) / reject fail-closed; the gate is explicit-over-inherited (ADR-002 decision 4) and precedes resolution - auth_publickey_offered: explicit accept-and-delegate (counting stays honest, POC-1) - auth_none / auth_password / auth_keyboard_interactive / auth_openssh_certificate: four explicit family-contract declines - username carried by no hook — resolution is fingerprint-only (OQ-03) - SessionHandler constructible via #[doc(hidden)] re-export (the w2-server-mount note's widening); resolve_bridge structured out of the #[non_exhaustive]-arg hooks so contract tests drive the bridge core directly - tests/auth_bridge.rs: 21 rows — wire rows over the real adapter mount (accept, fail-closed reject, username independence, revoke between attempts, none/password/certificate declines) + direct rows over the wave-1 doubles - cert fixture (throwaway ca + alpha-cert.pub) recorded in fingerprints.txt; MemHostKeySource double in tests/common Verification: cargo test (76 across the suite), --all-features, clippy --all-targets (both feature sets, -D warnings), fmt --check, doc --no-deps, taskgraph validate — all green
This commit is contained in:
1 parent
b90e89b600
commit
2202149fc0
9 files changed
+826
-34
No files matched your search
+168
-24
@@ -1,45 +1,189 @@
|
||||
//! 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).
|
||||
//! The per-connection server handler — the ADR-004 dispositional auth
|
||||
//! bridge, with the channel/request dispatch surface still standing at
|
||||
//! its inherited fail-closed russh defaults (`w2-dispatch-decline-core`
|
||||
//! fills those; `w2-auth-bridge` filled the auth hooks).
|
||||
//!
|
||||
//! 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.
|
||||
//! ## The auth bridge (ADR-004, decisions 1–5)
|
||||
//!
|
||||
//! 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.
|
||||
//! russh performs all cryptographic work: [`Handler::auth_publickey`]
|
||||
//! fires *after* signature and ownership verification, so this handler
|
||||
//! never sees or verifies a signature — its surface is dispositions
|
||||
//! only. The single resolution path: ed25519-only gate → the crate's
|
||||
//! one fingerprint path ([`crate::fingerprint::fingerprint`]) → the
|
||||
//! injected `IdentityProvider::resolve_from_fingerprint` → `Some`
|
||||
//! accepts with the resolved identity stored in per-connection handler
|
||||
//! state (it rides the session for the wave-3 seam propagation —
|
||||
//! [`Handler::auth_succeeded`] is the ride-along hook); `None` rejects
|
||||
//! fail-closed — the resolved-identity-`None` path never creates a
|
||||
//! weakly authorized session.
|
||||
//!
|
||||
//! Every other method is an **explicit** reject — [`Handler::auth_none`],
|
||||
//! [`Handler::auth_password`] and [`Handler::auth_keyboard_interactive`]
|
||||
//! each implemented, never inherited — so the posture survives a pin
|
||||
//! bump that could silently change russh's defaults (ADR-004 decision
|
||||
//! 3). The non-ed25519 algorithm decline is explicit-over-inherited too
|
||||
//! (ADR-002 decision 4). [`Handler::auth_publickey_offered`]
|
||||
//! accepts-and-delegates explicitly (the probe hook consumes no attempt
|
||||
//! — POC-1 verified; the delegation keeps `max_auth_attempts` counting
|
||||
//! honest).
|
||||
//!
|
||||
//! The username carries no semantics (OQ-03 resolved): resolution is
|
||||
//! fingerprint-only — the bridge core takes no username parameter at
|
||||
//! all, structurally refusing any identity input from the user string.
|
||||
//! Auth declines are *dispositions* (`Auth::Reject`), not errors — the
|
||||
//! error model stays key-source-only.
|
||||
//!
|
||||
//! ## Test surface
|
||||
//!
|
||||
//! The handler type is deliberately constructible outside this module
|
||||
//! (re-exported `#[doc(hidden)]` from the crate root) so the wave-1
|
||||
//! contract tests drive these hooks directly against the in-memory
|
||||
//! doubles — the wire-facing `VerifiedPublicKey`/`PublicKeyOffer` hook
|
||||
//! arguments are `#[non_exhaustive]` (only russh constructs them), so
|
||||
//! the bridge core is its own method ([`SessionHandler::resolve_bridge`])
|
||||
//! the tests call with ordinary key material; the wire-level rows
|
||||
//! exercise the hook wiring itself, end to end.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use russh::keys::Algorithm;
|
||||
use russh::server::{Auth, PublicKeyOffer, VerifiedPublicKey};
|
||||
|
||||
use crate::config::ServeConfig;
|
||||
use crate::error::AdapterError;
|
||||
use crate::fingerprint::fingerprint;
|
||||
|
||||
/// 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`].
|
||||
/// 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)]
|
||||
/// Carries the serve-config the auth bridge and dispatch consume, and
|
||||
/// the resolved identity once a publickey auth has been accepted (see
|
||||
/// [`Self::resolved_identity`] — the wave-3 seam reads it).
|
||||
pub struct SessionHandler {
|
||||
serve: Arc<ServeConfig>,
|
||||
resolved_identity: Option<Identity>,
|
||||
}
|
||||
|
||||
impl SessionHandler {
|
||||
pub(crate) fn new(serve: Arc<ServeConfig>) -> Self {
|
||||
Self { serve }
|
||||
/// Builds the handler over the serving configuration — the
|
||||
/// identity provider inside is the assembly-supplied
|
||||
/// `alkcall::core::auth::IdentityProvider`; the bridge only
|
||||
/// resolves through it.
|
||||
pub fn new(serve: Arc<ServeConfig>) -> Self {
|
||||
Self {
|
||||
serve,
|
||||
resolved_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity this connection currently rides — `Some` only after
|
||||
/// a fingerprint resolution accepted a publickey auth (the bridge's
|
||||
/// fail-closed rejecting paths never leave a weakly-authorized
|
||||
/// state behind). The wave-3 seam propagation consumes this.
|
||||
pub fn resolved_identity(&self) -> Option<&Identity> {
|
||||
self.resolved_identity.as_ref()
|
||||
}
|
||||
|
||||
/// The ADR-004 decision 1 bridge core, structured out of the
|
||||
/// [`Handler::auth_publickey`] hook so the contract tests can drive
|
||||
/// it directly: the hook's russh-owned argument shape
|
||||
/// (`russh::server::VerifiedPublicKey`) is `#[non_exhaustive]` —
|
||||
/// only russh constructs it — but this method takes the two facts
|
||||
/// the hook receives (the verified key and the signature's
|
||||
/// algorithm) as ordinary arguments.
|
||||
///
|
||||
/// Ed25519-only gate first (ADR-002 decision 4 — `SkEd25519` and
|
||||
/// every other scheme share the non-ed25519 decline, which is
|
||||
/// explicit-over-inherited so it survives a pin bump), then the
|
||||
/// single fingerprint path → the injected provider's
|
||||
/// `resolve_from_fingerprint`. No username parameter exists — the
|
||||
/// user string is never an identity input (OQ-03). `None` is the
|
||||
/// fail-closed reject disposition; `Some` carries the identity the
|
||||
/// hook stores in handler state.
|
||||
pub fn resolve_bridge(
|
||||
&self,
|
||||
public_key: &russh::keys::PublicKey,
|
||||
signature_algorithm: &Algorithm,
|
||||
) -> Option<Identity> {
|
||||
if *signature_algorithm != Algorithm::Ed25519
|
||||
|| public_key.algorithm() != Algorithm::Ed25519
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.serve
|
||||
.identity_provider
|
||||
.resolve_from_fingerprint(&fingerprint(public_key))
|
||||
}
|
||||
}
|
||||
|
||||
impl russh::server::Handler for SessionHandler {
|
||||
type Error = AdapterError;
|
||||
|
||||
/// ADR-004 decision 1 (as amended for ADR-002 decision 4): the
|
||||
/// verified key bridges fingerprint →
|
||||
/// `IdentityProvider::resolve_from_fingerprint` →
|
||||
/// accept-with-identity / reject fail-closed; non-ed25519 keys
|
||||
/// decline explicitly before any resolution. The resolved identity
|
||||
/// lands in the per-connection handler state.
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
_user: &str,
|
||||
key: VerifiedPublicKey<'_>,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
match self.resolve_bridge(key.public_key, key.algorithm) {
|
||||
Some(identity) => {
|
||||
self.resolved_identity = Some(identity);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
None => Ok(Auth::reject()),
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-004 decision 2: accept-and-delegate, explicit — the probe
|
||||
/// hook consumes no auth attempt (POC-1 verified), so keeping it at
|
||||
/// `Auth::Accept` keeps the `max_auth_attempts` counting honest;
|
||||
/// the real disposition is [`Self::auth_publickey`]'s.
|
||||
async fn auth_publickey_offered(
|
||||
&mut self,
|
||||
_user: &str,
|
||||
_offer: PublicKeyOffer<'_>,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
|
||||
/// ADR-004 decision 3: explicit family-contract decline — never the
|
||||
/// inherited default (a pin bump could silently change it).
|
||||
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
|
||||
/// ADR-004 decision 3: explicit family-contract decline — password
|
||||
/// auth does not exist on this door; ADR-005's key custody is the
|
||||
/// whole story.
|
||||
async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
|
||||
/// ADR-004 decision 3 + OQ-04 (resolved): explicit decline — the
|
||||
/// CA-trust story does not exist in the family identity model;
|
||||
/// future support is new-ADR territory.
|
||||
async fn auth_openssh_certificate(
|
||||
&mut self,
|
||||
_user: &str,
|
||||
_certificate: &russh::keys::Certificate,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
|
||||
/// ADR-004 decision 3: explicit family-contract decline.
|
||||
async fn auth_keyboard_interactive(
|
||||
&mut self,
|
||||
_user: &str,
|
||||
_submethods: &str,
|
||||
_response: Option<russh::server::Response<'_>>,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
Ok(Auth::reject())
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,12 @@ pub use adapter::SshAdapter;
|
||||
pub use config::{DialConfig, Limits, ServeConfig};
|
||||
pub use error::{AdapterError, KeySourceError};
|
||||
pub use fingerprint::fingerprint;
|
||||
/// Contract-test surface (`#[doc(hidden)]`): the per-connection auth
|
||||
/// bridge handler — constructible so the tests drive its hooks directly
|
||||
/// against the wave-1 doubles (see `handler.rs` §Test surface). Not
|
||||
/// documented API: the wire-facing surface is exactly `SshAdapter` +
|
||||
/// the config/error/host/key-source types.
|
||||
#[doc(hidden)]
|
||||
pub use handler::SessionHandler;
|
||||
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
|
||||
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
|
||||
Reference in new issue
Block a user