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:
glm-5.3-flash committed 2026-10-28 08:33:57 +00:00
1 parent b90e89b600
commit 2202149fc0
9 files changed
+826 -34

No files matched your search

+168 -24
View File
@@ -1,45 +1,189 @@
//! The per-connection server handler — the wave-2 skeleton the later //! The per-connection server handler — the ADR-004 dispositional auth
//! wave-2 tasks fill (`w2-auth-bridge` the auth hooks, //! bridge, with the channel/request dispatch surface still standing at
//! `w2-dispatch-decline-core` the channel/request dispatch, this file //! its inherited fail-closed russh defaults (`w2-dispatch-decline-core`
//! the substrate only). //! fills those; `w2-auth-bridge` filled the auth hooks).
//! //!
//! Skeleton posture (AGENTS.md convention + ADR-004): every //! ## The auth bridge (ADR-004, decisions 1–5)
//! [`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 //! russh performs all cryptographic work: [`Handler::auth_publickey`]
//! decline-matrix tasks gate from — the skeleton declines everything //! fires *after* signature and ownership verification, so this handler
//! by construction, never advertises a capability it does not serve. //! 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 std::sync::Arc;
use alkcall::core::auth::Identity;
use russh::keys::Algorithm;
use russh::server::{Auth, PublicKeyOffer, VerifiedPublicKey};
use crate::config::ServeConfig; use crate::config::ServeConfig;
use crate::error::AdapterError; use crate::error::AdapterError;
use crate::fingerprint::fingerprint;
/// The per-connection handler (one per mounted stream): russh constructs /// The per-connection handler (one per mounted stream): russh constructs
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted /// 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 /// Carries the serve-config the auth bridge and dispatch consume, and
/// the wave-2 skeleton stage everything it does is inherited — and the /// the resolved identity once a publickey auth has been accepted (see
/// inherited surface is fail-closed (auth rejects, channels decline), /// [`Self::resolved_identity`] — the wave-3 seam reads it).
/// so a mounted session through this skeleton serves nothing. pub struct SessionHandler {
pub(crate) struct SessionHandler {
#[allow(dead_code)]
serve: Arc<ServeConfig>, serve: Arc<ServeConfig>,
resolved_identity: Option<Identity>,
} }
impl SessionHandler { impl SessionHandler {
pub(crate) fn new(serve: Arc<ServeConfig>) -> Self { /// Builds the handler over the serving configuration — the
Self { serve } /// 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 { impl russh::server::Handler for SessionHandler {
type Error = AdapterError; 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())
}
} }
+7
View File
@@ -36,5 +36,12 @@ pub use adapter::SshAdapter;
pub use config::{DialConfig, Limits, ServeConfig}; pub use config::{DialConfig, Limits, ServeConfig};
pub use error::{AdapterError, KeySourceError}; pub use error::{AdapterError, KeySourceError};
pub use fingerprint::fingerprint; 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 host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource}; pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
+115 -9
View File
@@ -1,7 +1,7 @@
--- ---
id: w2-auth-bridge id: w2-auth-bridge
name: Wave 2 — the auth bridge (dispositional handler surface) name: Wave 2 — the auth bridge (dispositional handler surface)
status: pending status: completed
depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles]
scope: moderate scope: moderate
risk: low risk: low
@@ -44,19 +44,19 @@ the wire in `w2-loopback-harness`.
## Acceptance Criteria ## Acceptance Criteria
- [ ] `auth_publickey`: non-ed25519 → explicit reject; ed25519 → - [x] `auth_publickey`: non-ed25519 → explicit reject; ed25519 →
fingerprint → `resolve_from_fingerprint` → accept-with-identity fingerprint → `resolve_from_fingerprint` → accept-with-identity
/ reject fail-closed; the resolved identity lands in / reject fail-closed; the resolved identity lands in
per-connection handler state per-connection handler state
- [ ] `auth_publickey_offered` accepts-and-delegates explicitly - [x] `auth_publickey_offered` accepts-and-delegates explicitly
- [ ] `auth_none` / `auth_password` / `auth_keyboard_interactive` / - [x] `auth_none` / `auth_password` / `auth_keyboard_interactive` /
`auth_openssh_certificate` each explicitly reject `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) 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 `MemIdentityProvider`), unknown-fingerprint reject, non-ed25519
explicit reject, every non-publickey method rejects 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 `cargo fmt --check` pass
## References ## References
@@ -73,8 +73,114 @@ the wire in `w2-loopback-harness`.
## Notes ## 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 ## Summary
> To be filled on completion 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.
+474
View File
@@ -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<bool, Self::Error> {
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<MemIdentityProvider>,
limits: Limits,
) -> (
russh::client::Handle<AcceptAllHostKeys>,
tokio::task::JoinHandle<Result<(), AdapterError>>,
) {
let mut serve_config = ServeConfig::new(
Arc::clone(&provider) as Arc<dyn IdentityProvider>,
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<AcceptAllHostKeys>,
server_task: tokio::task::JoinHandle<Result<(), AdapterError>>,
) {
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<MemIdentityProvider> {
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<MemIdentityProvider>) -> SessionHandler {
SessionHandler::new(Arc::new(ServeConfig::new(
provider as Arc<dyn IdentityProvider>,
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<dyn IdentityProvider>,
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));
}
+41 -1
View File
@@ -55,7 +55,7 @@ use alkvault::{EncryptedData, VaultServiceHandle};
use async_trait::async_trait; use async_trait::async_trait;
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64}; 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 /// One committed ed25519 test-key fixture: the private OpenSSH-PEM
/// (`tests/vectors/<name>.key`), the public one-line blob /// (`tests/vectors/<name>.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<PrivateKey>,
}
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<Vec<PrivateKey>, KeySourceError> {
if self.keys.is_empty() {
Err(KeySourceError::Absent)
} else {
Ok(self.keys.clone())
}
}
}
/// The in-memory [`HostKeyPolicy`] — a configurable verdict double with /// The in-memory [`HostKeyPolicy`] — a configurable verdict double with
/// an inspectable decision log, for the wave-4 dial tests (and the /// an inspectable decision log, for the wave-4 dial tests (and the
/// case-1/case-2 rows of every host-key contract test). /// case-1/case-2 rows of every host-key contract test).
+1
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACALbynTlXiLkF68S8UsuIzX44b91bnCRckGIf6+KsVRcgAAAKhvsy24b7Mt
uAAAAAtzc2gtZWQyNTUxOQAAACALbynTlXiLkF68S8UsuIzX44b91bnCRckGIf6+KsVRcg
AAAEDbFLVv01Kg5qURZ8C46WGuY0QSJVTRm4OJyoK+horUQwtvKdOVeIuQXrxLxSy4jNfj
hv3VucJFyQYh/r4qxVFyAAAAImFsa3NzaCB0ZXN0IGNhICh0aHJvd2F3YXkgZml4dHVyZS
kBAgM=
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAtvKdOVeIuQXrxLxSy4jNfjhv3VucJFyQYh/r4qxVFy alkssh test ca (throwaway fixture)
+11
View File
@@ -14,3 +14,14 @@ alpha.pub SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A
bravo.pub SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0 bravo.pub SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0
charlie.pub SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U charlie.pub SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U
delta.pub SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs 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