diff --git a/src/handler.rs b/src/handler.rs index dd95671..d614d87 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -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` 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, + resolved_identity: Option, } impl SessionHandler { - pub(crate) fn new(serve: Arc) -> 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) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + Ok(Auth::reject()) + } + + /// ADR-004 decision 3: explicit family-contract decline. + async fn auth_keyboard_interactive( + &mut self, + _user: &str, + _submethods: &str, + _response: Option>, + ) -> Result { + Ok(Auth::reject()) + } } diff --git a/src/lib.rs b/src/lib.rs index 25dc3ae..5dfa6f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/tasks/implementation/w2-auth-bridge.md b/tasks/implementation/w2-auth-bridge.md index 4786594..0ea0992 100644 --- a/tasks/implementation/w2-auth-bridge.md +++ b/tasks/implementation/w2-auth-bridge.md @@ -1,7 +1,7 @@ --- id: w2-auth-bridge name: Wave 2 — the auth bridge (dispositional handler surface) -status: pending +status: completed depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] scope: moderate risk: low @@ -44,19 +44,19 @@ the wire in `w2-loopback-harness`. ## Acceptance Criteria -- [ ] `auth_publickey`: non-ed25519 → explicit reject; ed25519 → +- [x] `auth_publickey`: non-ed25519 → explicit reject; ed25519 → fingerprint → `resolve_from_fingerprint` → accept-with-identity / reject fail-closed; the resolved identity lands in per-connection handler state -- [ ] `auth_publickey_offered` accepts-and-delegates explicitly -- [ ] `auth_none` / `auth_password` / `auth_keyboard_interactive` / +- [x] `auth_publickey_offered` accepts-and-delegates explicitly +- [x] `auth_none` / `auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate` each explicitly reject -- [ ] The username is never an identity input (fingerprint-only +- [x] The username is never an identity input (fingerprint-only resolution; OQ-03) -- [ ] Handler-level tests: known-fingerprint accept (fixture key + +- [x] Handler-level tests: known-fingerprint accept (fixture key + `MemIdentityProvider`), unknown-fingerprint reject, non-ed25519 explicit reject, every non-publickey method rejects -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -73,8 +73,114 @@ the wire in `w2-loopback-harness`. ## Notes -> To be filled by implementation agent +Implementation record (decisions made at this task's discretion): + +- **The bridge core is structured out of the hook on purpose.** + `VerifiedPublicKey` and `PublicKeyOffer` are `#[non_exhaustive]` + with no external constructor — only russh builds them (and only + post-verification), so the *full* `auth_publickey`/`auth_publickey_offered` + hooks cannot be invoked directly from a test. The bridge is therefore + `SessionHandler::resolve_bridge(&public_key, &signature_algorithm)` — + the ed25519-only gate → the single fingerprint path → + `resolve_from_fingerprint` — a plain method the contract tests drive + with ordinary key material, and the hook is one line over it (call, + store on `Some`, `Auth::reject()` on `None`). The non-constructible + hooks are exercised as full hooks over the wire instead (see below). +- **Two test vehicles, both recorded.** Wire rows (the real + `SshAdapter` over a tokio-duplex pair + russh's own client — the + `w2-server-mount` wire-smoke precedent) drive the full + `auth_publickey` hook end to end (accept, fail-closed reject, + username independence, `auth_none`/`auth_password`/ + `auth_openssh_certificate` declines over the wire). Direct-hook rows + against `SessionHandler` (over the wave-1 doubles) drive every + hook with constructible arguments plus `resolve_bridge`. The transport + choice and harness vocabulary remain `w2-loopback-harness`'s to pin — + these rows are its thin precursor, not a competing vehicle. +- **`SessionHandler` visibility widened as `w2-server-mount`'s note + anticipated**: the struct is `pub` inside its still-private module, + re-exported `#[doc(hidden)]` from the crate root — constructible for + the contract tests, hidden from the docs; the documented public API + stays exactly `SshAdapter` + the config/error/host/key-source types. +- **Non-ed25519 decline testing at this pin**: no wire row can carry a + non-ed25519 key — `rsa`/`pkcs1` are dropped from the dependency tree + (ADR-002 decision 4's verification) and ssh-key ships without + ecdsa/dsa/rsa features, so no other key type even parses in-tree. + The gate is therefore tested on the bridge core with constructible + stand-in signature algorithms (`SkEd25519`, + `SkEcdsaSha2NistP256`, `Other(name@domain)`) — a registered + fingerprint under a non-ed25519 algorithm still declines, proving + the gate precedes resolution. The gate checks **both** algorithm + facts (the signature `.algorithm` from the hook and the key's + `.algorithm()`); at this pin the two can never disagree in-tree, and + the explicit-over-inherited implementation is what survives a pin + bump. +- **`auth_publickey_offered`'s accept-and-delegate is explicit but not + directly hook-testable at this pin** (non-exhaustive argument; the + russh client sends no `is_real=0` probe — OpenSSH-style clients do, + and POC-1 + russh's own `publickey_probes_do_not_burn_auth_attempts` + test remain the counting-honesty evidence). The wire rows exercise + the signed-request path (`is_real=1`) that passes through it. +- **`auth_succeeded` stays unimplemented (the russh default)** — the + identity rides in per-connection handler state + (`SessionHandler::resolved_identity()`, the fail-closed `None` + paths never store); wave 3's seam propagation consumes it there and + through `auth_succeeded`. The store is the hook's one line over the + same resolved value the wire rows exercise; its consumption is + wave-3's assertion point. +- **Certificate fixture added** (`tests/vectors/`): `ca.key`/`ca.pub` + (throwaway CA, committed on the same fixture rationale as every + other private half) and `alpha-cert.pub` — the alpha user + certificate signed by it (serial 1, key id `alkssh-fixture-alpha`, + principal `git`, validity 2026–2036). Both fingerprints recorded in + `fingerprints.txt`. `alpha-cert.pub` drives the + `auth_openssh_certificate` decline rows (wire and direct-hook); the + cert expiry/signature checks russh performs before the hook are + exercised by the valid fixture (the decline is the hook's, not + russh's pre-checks). +- **`MemHostKeySource` double added to `tests/common`** (the wave-1 + doubles' module, still the only doubles location): an + `absent()` shape (load refuses `Absent` — fail-closed if anything + ever loads it) and a `from_fixtures()` shape for the wire rows' real + adapter construction (which wants a non-empty host-key set). ## Summary -> To be filled on completion \ No newline at end of file +The ADR-004 dispositions are landed in `src/handler.rs`: +`auth_publickey` is the fingerprint → `resolve_from_fingerprint` → +accept-with-identity / reject fail-closed bridge (non-ed25519 keys +declined *explicitly* by the bridge-core gate before any resolution — +ADR-002 decision 4's explicit-over-inherited pattern), with the +resolved identity stored in per-connection handler state +(`SessionHandler::resolved_identity()`, read by wave 3); +`auth_publickey_offered` is an explicit accept-and-delegate; +`auth_none`/`auth_password`/`auth_keyboard_interactive`/ +`auth_openssh_certificate` are four explicit family-contract declines; +the username is structurally excluded from resolution (no username +parameter exists on the bridge core — OQ-03). Every decline is a +`Auth::Reject` disposition, never an error. + +Testing: `tests/auth_bridge.rs` — 7 wire rows over the real adapter +mount (registered-accept, unknown-reject with the `{publickey}` +advertisement surviving, four-username independence, revoke-between- +attempts, `auth_none`, `auth_password`, and the OpenSSH-certificate +decline — all via russh's own client over a duplex pair against the +committed cert fixture) + 7 direct rows against `SessionHandler` and +`resolve_bridge` over the wave-1 doubles (all four non-publickey hooks +reject explicitly with no identity state left behind; registered / +unknown / revoked fingerprints; the ed25519 gate before resolution +with sk-variant/Other stand-ins; the independent-issuer registration +parity row; the absent-host-key-source construction row) + 1 fixture +fingerprint-format row. 76 tests total across the suite. + +Verification: `cargo test` (47 lib + 14 auth_bridge + 9 doubles + +5 parity, all green), `cargo test --all-features` green, +`cargo clippy --all-targets -- -D warnings` green, +`cargo clippy --all-features --all-targets -- -D warnings` green, +`cargo fmt --check` green, `cargo doc --no-deps` green, +`taskgraph validate` green. The wire rows are noted as the thin +precursor of the `w2-loopback-harness` vehicle (which pins the +transport choice); flagged for the wave-2 gate: the +resolved-identity *landing* is code-verifiable only at wave 3 (the +hook's russh-owned argument shape is non-exhaustive and the handler +lives inside `run_stream`) — the store rides the same resolved value +the wire rows exercise. \ No newline at end of file diff --git a/tests/auth_bridge.rs b/tests/auth_bridge.rs new file mode 100644 index 0000000..1ecaad4 --- /dev/null +++ b/tests/auth_bridge.rs @@ -0,0 +1,474 @@ +//! The wave-2 auth-bridge contract tests (`w2-auth-bridge`) — the +//! ADR-004 dispositions at the handler level and over the wire. +//! +//! Two vehicles, each for a structural reason: +//! +//! - **Wire rows** (the adapter's tokio-duplex mount + russh's own +//! client, the `w2-server-mount` wire-smoke precedent) drive the full +//! `auth_publickey` hook end to end — the hook's +//! `VerifiedPublicKey`/`PublicKeyOffer` argument shapes are +//! `#[non_exhaustive]` (only russh constructs them, post-verification), +//! so wire traffic is the only way to observe the full hook — exactly +//! where POC-1 observed it. These rows are the thin in-process +//! precursor of the dedicated `w2-loopback-harness` vehicle (which +//! records the transport choice and the helpers' vocabulary). +//! - **Direct-hook rows** (the wave-1 doubles against +//! [`alkssh::SessionHandler`] constructed directly) drive every hook +//! whose arguments are constructible outside russh — the four +//! non-publickey declines, and the bridge core +//! ([`alkssh::SessionHandler::resolve_bridge`], structured out of the +//! `#[non_exhaustive]`-arg hook on purpose) with fixture keys + +//! `MemIdentityProvider`. The non-constructible-arg hooks +//! (`auth_publickey`, `auth_publickey_offered`) are the wire rows' +//! business. +//! +//! The rows are contract stamps: each names the pinning ADR § (the +//! greppable `Contract stamp:` inventory applies). + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use alkcall::core::auth::IdentityProvider; +use common::{FIXTURE_KEYS, MemHostKeySource, MemIdentityProvider, fixture, identity}; +use russh::client::AuthResult; +use russh::keys::{Algorithm, Certificate, PrivateKeyWithHashAlg}; +use russh::server::{Auth, Handler}; + +use alkssh::{AdapterError, Limits, ServeConfig, SessionHandler, SshAdapter, fingerprint}; +use alkssh::{HostKeySource, KeySourceError}; + +/// The client-side handler double for the wire rows — accept every +/// host key (the test choice; the crate never embeds this posture). +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) + } +} + +/// Mounts the real adapter stack over an in-process duplex pair and +/// connects a russh client to it — the wire rows' vehicle. +async fn mount( + provider: Arc, + limits: Limits, +) -> ( + russh::client::Handle, + tokio::task::JoinHandle>, +) { + let mut serve_config = ServeConfig::new( + Arc::clone(&provider) as Arc, + Arc::new(MemHostKeySource::from_fixtures(&[fixture("bravo")])), + ); + serve_config.limits = limits; + + let adapter = SshAdapter::new(serve_config) + .await + .expect("adapter constructs (host keys loaded)"); + 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 client = russh::client::connect_stream(client_config, client_io, AcceptAllHostKeys) + .await + .expect("client mounts against the server stack"); + (client, server_task) +} + +/// Ends one wire row cleanly: disconnect, then the server task's end. +async fn unmount( + client: russh::client::Handle, + server_task: tokio::task::JoinHandle>, +) { + client + .disconnect(russh::Disconnect::ByApplication, "row done", "en") + .await + .expect("client disconnects cleanly"); + server_task + .await + .expect("server task ends") + .expect("clean session end"); +} + +fn registered_provider(fingerprints: &[&str]) -> Arc { + let provider = Arc::new(MemIdentityProvider::new()); + for (index, registered) in fingerprints.iter().enumerate() { + provider.register(registered, identity(&format!("user-{index}"), &["ssh"])); + } + provider +} + +/// - *Contract stamp:* ADR-004 decision 1 (fingerprint → +/// `resolve_from_fingerprint` → `Some` → `Auth::Accept` — the bridge +/// accepts the identity's key with the arbitrary username attached). +#[tokio::test] +async fn known_fingerprint_accepts_over_the_wire() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(provider, Limits::default()).await; + + let result = client + .authenticate_publickey( + "anyone", + PrivateKeyWithHashAlg::new(Arc::new(fixture("alpha").private_key()), None), + ) + .await + .expect("auth exchange completes over the wire"); + + assert!( + result.success(), + "registered fingerprint must accept: {result:?}" + ); + unmount(client, server_task).await; +} + +/// - *Contract stamp:* ADR-004 decision 5 + OQ-03 (the username is not +/// identity and carries no semantics — keys resolve independently of +/// the username string, POC-1's `ssh anything@host` observation). +#[tokio::test] +async fn username_is_never_an_identity_input() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + + for username in ["anyone", "git", "root", "alkforge"] { + let (mut client, server_task) = mount(Arc::clone(&provider), Limits::default()).await; + let result = client + .authenticate_publickey( + username, + PrivateKeyWithHashAlg::new(Arc::new(fixture("alpha").private_key()), None), + ) + .await + .expect("auth exchange completes over the wire"); + assert!( + result.success(), + "every username authenticates identically — {username}: {result:?}" + ); + unmount(client, server_task).await; + } +} + +/// - *Contract stamp:* ADR-004 decision 1 (`None` → reject — the +/// fail-closed path never creates a weakly authorized session) and +/// the advertisement honesty (ADR-004 decision 4 — exactly +/// `{publickey}` remains advertised on the decline). +#[tokio::test] +async fn unknown_fingerprint_rejects_fail_closed() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(provider, Limits::default()).await; + + let result = client + .authenticate_publickey( + "anyone", + PrivateKeyWithHashAlg::new(Arc::new(fixture("delta").private_key()), None), + ) + .await + .expect("auth exchange completes over the wire"); + + let remaining = match &result { + AuthResult::Failure { + remaining_methods, .. + } => remaining_methods.clone(), + other => panic!("unregistered fingerprint must reject, got {other:?}"), + }; + assert!(!result.success()); + assert_eq!( + remaining, + russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]), + "the publickey-only advertisement survives the decline" + ); + unmount(client, server_task).await; +} + +/// - *Contract stamp:* ADR-004 decision 1 (resolution happens at auth +/// time — a post-resolution revocation rejects the *next* attempt; +/// nothing is cached into a session credential). +#[tokio::test] +async fn resolved_fingerprint_can_be_revoked_between_attempts() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(Arc::clone(&provider), Limits::default()).await; + + let first = client + .authenticate_publickey( + "anyone", + PrivateKeyWithHashAlg::new(Arc::new(fixture("alpha").private_key()), None), + ) + .await + .expect("auth exchange completes"); + assert!(first.success(), "registered fingerprint accepts: {first:?}"); + unmount(client, server_task).await; + + provider.revoke(fixture("alpha").fingerprint); + + let (mut client, server_task) = mount(provider, Limits::default()).await; + let second = client + .authenticate_publickey( + "anyone", + PrivateKeyWithHashAlg::new(Arc::new(fixture("alpha").private_key()), None), + ) + .await + .expect("auth exchange completes"); + assert!(!second.success(), "revoked fingerprint rejects: {second:?}"); + unmount(client, server_task).await; +} + +/// - *Contract stamp:* ADR-004 decision 3 (`auth_none` is an explicit +/// family-contract decline — non-advertised methods are never +/// *advertised*, but the disposition is every attempted method's own +/// hook, not the advertisement's). +#[tokio::test] +async fn auth_none_rejects_over_the_wire() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(provider, Limits::default()).await; + + let result = client + .authenticate_none("anyone") + .await + .expect("auth exchange completes over the wire"); + assert!( + !result.success(), + "the none method must never authenticate: {result:?}" + ); + unmount(client, server_task).await; +} + +/// - *Contract stamp:* ADR-004 decision 3 (`auth_password` explicit +/// decline; ADR-005's key custody is the whole client-side story). +#[tokio::test] +async fn auth_password_rejects_over_the_wire() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(provider, Limits::default()).await; + + let result = client + .authenticate_password("anyone", "whatever") + .await + .expect("auth exchange completes over the wire"); + assert!( + !result.success(), + "the password method must never authenticate: {result:?}" + ); + unmount(client, server_task).await; +} + +/// - *Contract stamp:* ADR-004 decision 3 + OQ-04 (the OpenSSH +/// certificate decline is the standing disposition — no CA-trust +/// story exists in the family identity model). +#[tokio::test] +async fn auth_openssh_certificate_rejects_over_the_wire() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let (mut client, server_task) = mount(provider, Limits::default()).await; + + let cert = + Certificate::from_openssh(include_str!("vectors/alpha-cert.pub")).expect("cert fixture"); + let result = client + .authenticate_openssh_cert("anyone", Arc::new(fixture("alpha").private_key()), cert) + .await + .expect("auth exchange completes over the wire"); + assert!( + !result.success(), + "an OpenSSH certificate must never authenticate: {result:?}" + ); + unmount(client, server_task).await; +} + +fn handler_with(provider: Arc) -> SessionHandler { + SessionHandler::new(Arc::new(ServeConfig::new( + provider as Arc, + Arc::new(MemHostKeySource::absent()), + ))) +} + +/// - *Contract stamp:* ADR-004 decision 3 (each non-publickey method is +/// an *implemented* decline, never the inherited default — the +/// dispositions are family contract, and each one is its own hook). +#[tokio::test] +async fn every_non_publickey_hook_rejects_explicitly() { + let mut handler = handler_with(registered_provider(&[fixture("alpha").fingerprint])); + + let none = handler.auth_none("anyone").await.expect("hook resolves"); + assert_eq!(none, Auth::reject(), "auth_none must decline"); + + let password = handler + .auth_password("anyone", "whatever") + .await + .expect("hook resolves"); + assert_eq!(password, Auth::reject(), "auth_password must decline"); + + let keyboard = handler + .auth_keyboard_interactive("anyone", "", None) + .await + .expect("hook resolves"); + assert_eq!( + keyboard, + Auth::reject(), + "auth_keyboard_interactive must decline" + ); + + let cert = Certificate::from_openssh(include_str!("vectors/alpha-cert.pub")) + .expect("cert fixture parses"); + let certificate = handler + .auth_openssh_certificate("anyone", &cert) + .await + .expect("hook resolves"); + assert_eq!( + certificate, + Auth::reject(), + "auth_openssh_certificate must decline" + ); + + assert!( + handler.resolved_identity().is_none(), + "no decline path may leave identity state behind" + ); +} + +/// - *Contract stamp:* ADR-004 decision 1 (fingerprint → +/// `resolve_from_fingerprint` → `Some`, the single resolution shape, +/// with the crate's single fingerprint path computing the handle the +/// doubles registered). +#[tokio::test] +async fn bridge_core_resolves_registered_fingerprints() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let handler = handler_with(provider); + let key = fixture("alpha").public_key(); + + let resolved = handler + .resolve_bridge(&key, &Algorithm::Ed25519) + .expect("registered fingerprint resolves"); + assert_eq!(resolved.id, "user-0", "the identity rides to the handler"); + assert!( + handler.resolved_identity().is_none(), + "resolve_bridge only resolves — the hook stores; an unwired bridge leaves no state" + ); +} + +/// - *Contract stamp:* ADR-004 decision 1 (`None` → reject — the +/// unknown / revoked fingerprint never resolves; fail-closed, no +/// weakly-authorized session, no cached resolution). +#[tokio::test] +async fn bridge_core_rejects_unknown_and_revoked_fingerprints() { + let provider = Arc::new(MemIdentityProvider::new()); + let handler = handler_with(Arc::clone(&provider)); + let key = fixture("delta").public_key(); + + assert!( + handler.resolve_bridge(&key, &Algorithm::Ed25519).is_none(), + "an unregistered fingerprint resolves to the reject disposition" + ); + + provider.register( + fixture("delta").fingerprint, + identity("user-delta", &["ssh"]), + ); + assert!( + handler.resolve_bridge(&key, &Algorithm::Ed25519).is_some(), + "the registration lands" + ); + + provider.revoke(fixture("delta").fingerprint); + assert!( + handler.resolve_bridge(&key, &Algorithm::Ed25519).is_none(), + "the revocation takes — no cached resolution survives" + ); +} + +/// - *Contract stamp:* ADR-002 decision 4 (ed25519-only: the +/// non-ed25519 decline is explicit-over-inherited and precedes any +/// resolution — a registered fingerprint under a non-ed25519 +/// algorithm still declines). The sk-variant and `Other` stand-ins +/// cover the scheme classes the in-tree build cannot even parse +/// (rsa/dsa are dropped from the dependency tree and ssh-key ships +/// without ecdsa/dsa/rsa features here, so no wire row can drive a +/// non-ed25519 key at this pin — recorded in the task notes; the +/// gate is the explicit-over-inherited posture for the bump). +#[tokio::test] +async fn bridge_core_gates_non_ed25519_algorithms_before_resolution() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let handler = handler_with(provider); + let key = fixture("alpha").public_key(); + + assert_eq!(key.algorithm(), Algorithm::Ed25519); + for non_ed25519 in [ + Algorithm::SkEd25519, + Algorithm::SkEcdsaSha2NistP256, + Algorithm::Other( + "rsa-class@alkssh.gate-vector" + .parse() + .expect("algorithm name parses"), + ), + ] { + let resolved = handler.resolve_bridge(&key, &non_ed25519); + assert!( + resolved.is_none(), + "the ed25519-only gate precedes resolution: {non_ed25519:?}" + ); + } + + assert!( + handler.resolve_bridge(&key, &Algorithm::Ed25519).is_some(), + "the same registered fingerprint resolves under the ed25519 algorithm" + ); +} + +/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint the bridge +/// computes is byte-format identical to the independent-issuer +/// registrations — the wave-1 parity vectors pin the string; this row +/// pins that the bridge resolves against exactly those values). +#[tokio::test] +async fn bridge_core_fingerprint_is_the_registered_value() { + let provider = Arc::new(MemIdentityProvider::new()); + provider.register( + fixture("bravo").fingerprint, + identity("user-bravo", &["ssh"]), + ); + let handler = handler_with(provider); + + let resolved = handler + .resolve_bridge(&fixture("bravo").public_key(), &Algorithm::Ed25519) + .expect("independent-issuer registration value resolves"); + assert_eq!(resolved.id, "user-bravo"); +} + +/// - *Contract stamp:* ADR-004 decision 1 (`SHA256:` + unpadded +/// standard-alphabet base64 — the wave-1 record, re-asserted against +/// every committed fixture's `ssh-keygen -lf` value). +#[test] +fn every_fixture_fingerprint_format_stays_the_contract_invariant_shape() { + for fixture_key in FIXTURE_KEYS { + let computed = fingerprint(&fixture_key.public_key()); + assert_eq!( + computed, fixture_key.fingerprint, + "fixture {} must keep its recorded ssh-keygen value", + fixture_key.name + ); + } +} + +/// - *Contract stamp:* ADR-005 decision 6 (host-key loading is the +/// construction's business, never the auth hooks' — the bridge +/// handler builds over an absent host-key source and never loads it, +/// and the absent shape still refuses fail-closed if it were). +#[tokio::test] +async fn handler_constructs_over_an_absent_host_key_source_without_loading_it() { + let provider = registered_provider(&[]); + let serve_config = ServeConfig::new( + provider as Arc, + Arc::new(MemHostKeySource::absent()), + ); + let handler = SessionHandler::new(Arc::new(serve_config)); + + assert!(handler.resolved_identity().is_none(), "no auth yet"); + + let err = MemHostKeySource::absent() + .load() + .await + .expect_err("the absent shape refuses"); + assert!(matches!(err, KeySourceError::Absent)); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fdd510e..45dbe0f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -55,7 +55,7 @@ use alkvault::{EncryptedData, VaultServiceHandle}; use async_trait::async_trait; use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64}; -use alkssh::{HostKeyPolicy, PresentedHostKey}; +use alkssh::{HostKeyPolicy, HostKeySource, KeySourceError, PresentedHostKey}; /// One committed ed25519 test-key fixture: the private OpenSSH-PEM /// (`tests/vectors/.key`), the public one-line blob @@ -269,6 +269,46 @@ impl TestVault { } } +/// The in-memory [`HostKeySource`] double — a static decrypted key set +/// (wave-2 addition): the wire-shaped rows construct a real +/// `SshAdapter`, whose construction wants a non-empty host-key set, and +/// the direct-hook rows want a source that is never loaded (the handler +/// construction loads nothing — host keys are the russh `Config`'s +/// business, built in [`alkssh::SshAdapter::new`]). +/// +/// `absent()` is the empty-set shape — its load refuses with +/// [`KeySourceError::Absent`] (the fail-closed contract), so any row +/// that accidentally loads an absent double fails loudly. +pub struct MemHostKeySource { + keys: Vec, +} + +impl MemHostKeySource { + /// The never-loaded / absent shape. + pub fn absent() -> Self { + Self { keys: Vec::new() } + } + + /// A static decrypted set from the fixture keys — the wire rows' + /// host-key set (e.g. `["bravo"]`, the rotation-pair member). + pub fn from_fixtures(keys: &[&'static FixtureKey]) -> Self { + Self { + keys: keys.iter().map(|k| k.private_key()).collect(), + } + } +} + +#[async_trait] +impl HostKeySource for MemHostKeySource { + async fn load(&self) -> Result, KeySourceError> { + if self.keys.is_empty() { + Err(KeySourceError::Absent) + } else { + Ok(self.keys.clone()) + } + } +} + /// The in-memory [`HostKeyPolicy`] — a configurable verdict double with /// an inspectable decision log, for the wave-4 dial tests (and the /// case-1/case-2 rows of every host-key contract test). diff --git a/tests/vectors/alpha-cert.pub b/tests/vectors/alpha-cert.pub new file mode 100644 index 0000000..0416a3e --- /dev/null +++ b/tests/vectors/alpha-cert.pub @@ -0,0 +1 @@ +ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIOBvZ5e2rz/imrfzoDBHAMh8Y3tLHBqY+s81mRev5zK3AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14zAAAAAAAAAAEAAAABAAAAFGFsa3NzaC1maXh0dXJlLWFscGhhAAAABwAAAANnaXQAAAAAaVW5AAAAAAB8JF8AAAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgC28p05V4i5BevEvFLLiM1+OG/dW5wkXJBiH+virFUXIAAABTAAAAC3NzaC1lZDI1NTE5AAAAQFWDEudf4jc8ERsBpoO97/xHfPuBCwr2VaVjmfj9wfgBKrrF+GtW23hkYuI+JACk3D0omjlXo0hfm+ljfaS57wI= alkssh-w1-fixture-alpha diff --git a/tests/vectors/ca.key b/tests/vectors/ca.key new file mode 100644 index 0000000..aa808cc --- /dev/null +++ b/tests/vectors/ca.key @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACALbynTlXiLkF68S8UsuIzX44b91bnCRckGIf6+KsVRcgAAAKhvsy24b7Mt +uAAAAAtzc2gtZWQyNTUxOQAAACALbynTlXiLkF68S8UsuIzX44b91bnCRckGIf6+KsVRcg +AAAEDbFLVv01Kg5qURZ8C46WGuY0QSJVTRm4OJyoK+horUQwtvKdOVeIuQXrxLxSy4jNfj +hv3VucJFyQYh/r4qxVFyAAAAImFsa3NzaCB0ZXN0IGNhICh0aHJvd2F3YXkgZml4dHVyZS +kBAgM= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/vectors/ca.pub b/tests/vectors/ca.pub new file mode 100644 index 0000000..62b900c --- /dev/null +++ b/tests/vectors/ca.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAtvKdOVeIuQXrxLxSy4jNfjhv3VucJFyQYh/r4qxVFy alkssh test ca (throwaway fixture) diff --git a/tests/vectors/fingerprints.txt b/tests/vectors/fingerprints.txt index 1a00fd8..9d532c9 100644 --- a/tests/vectors/fingerprints.txt +++ b/tests/vectors/fingerprints.txt @@ -14,3 +14,14 @@ alpha.pub SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A bravo.pub SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0 charlie.pub SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U delta.pub SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs +ca.pub SHA256:mPAq1aRKBuyH3gZs9IcLWtc1X3AhYi50P80mpo7XPSw +# alpha-cert.pub: the alpha user certificate (serial 1, key id +# `alkssh-fixture-alpha`, principal `git`, validity 2026–2036) — the +# ssh-keygen output above the fingerprint line is its OpenSSH text; the +# certificate's embedded public half is alpha.pub (the same fingerprint +# as alpha.pub; `ssh-keygen -lf alpha-cert.pub` reports it with the +# ED25519-CERT type). The cert was signed by the throwaway ca key at +# vector-generation time (w2-auth-bridge's auth_openssh_certificate +# decline fixture). `ca.key`'s private half is committed on the same +# fixture rationale as every other private half here. +alpha-cert.pub SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A