//! 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)); }