Files
alkssh/tests/common/mod.rs
T
glm-5.3-flash 3b77193e85 feat(tests): land the loopback harness — the in-process contract-test vehicle (w2-loopback-harness)
- tests/common/harness.rs: the recorded transport decision (tokio
  duplex pair + russh connect_stream; recorded over TCP loopback for
  determinism, no-port portability) and placement decision (sibling
  of the wave-1 doubles, the crate's contract-test vehicle convention)
- the client-side posture from the doubles: MemHostKeyPolicy bridged
  into russh's check_server_key (HarnessClientHandler, decision input
  byte-equal to the wave-4 dial wiring's), the Limits keepalive/
  inactivity posture (client_config), fixture-key auth helpers
- thin helpers: authenticate_publickey + remaining_methods,
  open_session_channel (open-failure reason observed in the Err),
  request_exec/shell/subsystem, next_message / await_exit_status /
  await_close, drive → LoopbackSession with clean unmount
- acceptance proof in tests/loopback_harness.rs: 6 contract-stamped
  rows, incl. the full in-process connect → fixture-key auth →
  authenticated-session observation (host-key decision log +
  observed decline reason) and the fail-closed host-key decline

Verification: cargo test (81 pass), cargo clippy --all-targets
-- -D warnings, cargo fmt --check, taskgraph validate — all green.
2026-10-28 08:42:15 +00:00

415 lines
16 KiB
Rust

//! alkssh's wave-1 test doubles — the in-memory seams every later
//! wave's contract tests run against (the alkgit `tests/common/`
//! convention, adapted): an in-memory `IdentityProvider`
//! ([`MemIdentityProvider`]), the in-memory vault seam ([`TestVault`]),
//! an in-memory `HostKeyPolicy` ([`MemHostKeyPolicy`]), and the
//! ed25519 test-key fixtures ([`FIXTURE_KEYS`]).
//!
//! **Location decision (recorded, the OQ-03 freeze-inventory
//! discipline):** the doubles live *only* here — a shared module under
//! `tests/` reached with `mod common;` — and never in the public API:
//! no re-export from `lib.rs`, no feature gate, nothing doubles-related
//! reachable from the crate root. The doubles' shapes are test
//! surface, test-versioned freely; the only `src/`-side consumers are
//! `#[cfg(test)]` modules reading the fixture *files* through
//! `include_str!`, never a public export.
//!
//! **Vault double decision (recorded, flagged for the wave-1 gate):**
//! alkvault 0.1 ships an in-memory construction —
//! `VaultServiceHandle::new()` is an in-process vault that
//! `unlock_new` arms in-memory. The double is therefore a *wrapper*
//! ([`TestVault`]) around that construction, not a local re-impl
//! behind the source trait: the same decrypt error taxonomy, GCM
//! integrity behavior, and rotation semantics the production source
//! seam sees are exercised verbatim, and the POC-2 §3 failure postures
//! (locked / absent / tampered / wrong seed) ride the real seams.
//!
//! **Fixture decision (recorded):** committed static OpenSSH-PEM test
//! keys, minted by `ssh-keygen` at vector-generation time (the
//! independent issuer), never generated at test time — determinism and
//! no tooling dependency. The committed private halves are throwaway
//! fixtures, not secrets: minted for this suite only, never registered
//! anywhere real, never deployed. Each key's fingerprint is recorded
//! byte-verbatim from `ssh-keygen -lf` in
//! `tests/vectors/fingerprints.txt`; [`FIXTURE_KEYS`] carries the
//! recorded values and every fingerprint comparison in the suite
//! asserts parity against them.
//!
//! Each test binary's `mod common;` compiles the whole shared module
//! and no single binary exercises every double — dead_code is allowed
//! module-wide so one binary's suite never fails the next's.
//!
//! The row/stamp discipline (adapted from the sibling crates): test
//! functions are *rows* — the name states the property, not the
//! caller — and each carries a doc-comment list item beginning
//! **`Contract stamp:`** citing the pinning spec §/ADR § (never a bare
//! wave or date). The marker is greppable:
//! `grep -rn "Contract stamp:" tests/` is the inventory.
#![allow(dead_code)]
/// The loopback harness — *the* contract-test vehicle (its own
/// location/transport decisions live in its module docs:
/// `tests/common/harness.rs`).
pub mod harness;
use std::collections::HashMap;
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
use alkvault::{EncryptedData, VaultServiceHandle};
use async_trait::async_trait;
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
use alkssh::{HostKeyPolicy, HostKeySource, KeySourceError, PresentedHostKey};
/// One committed ed25519 test-key fixture: the private OpenSSH-PEM
/// (`tests/vectors/<name>.key`), the public one-line blob
/// (`tests/vectors/<name>.pub`), and the fingerprint recorded
/// byte-verbatim from `ssh-keygen -lf` (`tests/vectors/fingerprints.txt`).
///
/// The `pem`/`blob`/`fingerprint` values are committed constants — throwaway
/// fixture material, never registered or deployed anywhere real.
#[derive(Debug)]
pub struct FixtureKey {
/// The fixture's name (`alpha` … `delta`).
pub name: &'static str,
/// The OpenSSH private-key PEM, included verbatim from the
/// committed fixture file.
pub pem: &'static str,
/// The public key's base64 blob — the OpenSSH one-line encoding,
/// `ssh-ed25519 <blob>` stripped of the key-type prefix and comment.
pub blob: &'static str,
/// The fingerprint recorded from `ssh-keygen -lf` — the
/// independent-issuer value every comparison in the suite asserts
/// parity against (ADR-004 decision 1's contract invariant).
pub fingerprint: &'static str,
}
impl FixtureKey {
/// The decoded public key (parity input; ADR-004 decision 1).
pub fn public_key(&self) -> PublicKey {
parse_public_key_base64(self.blob).expect("committed fixture blob parses")
}
/// The decoded private key — `decode_secret_key(pem, None)`, the
/// ADR-005 decision 2 conversion (the fixtures' inner format is
/// exactly the at-rest contract: unencrypted OpenSSH PEM).
pub fn private_key(&self) -> PrivateKey {
decode_secret_key(self.pem, None).expect("committed fixture PEM decodes")
}
}
/// The wave-1 fixture set: four minted ed25519 keys. `alpha` is the
/// canonical client-dial fixture; `bravo`/`charlie` are the host-key
/// pair (rotation/multi-key rows); `delta` is the independent extra
/// (unregistered-peer, mismatch, and wrong-key rows).
pub const FIXTURE_KEYS: &[FixtureKey] = &[
FixtureKey {
name: "alpha",
pem: include_str!("../vectors/alpha.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIM28sP5UAl3npJJ8trZgPt2kE+Af6477/Rx6J2VbM14z",
fingerprint: "SHA256:YyaiL42Xl8tcRRUhNCiqQBsiiRWVHHDR9hpXC45EQ6A",
},
FixtureKey {
name: "bravo",
pem: include_str!("../vectors/bravo.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIMvu9S+hH9Dug7fdiHLeW+B9kee+i495jPAdBH8YaOvQ",
fingerprint: "SHA256:yQf/w2wYBoZ9adDlRGbcNh7AHE3mbqWRCqoGHra1ib0",
},
FixtureKey {
name: "charlie",
pem: include_str!("../vectors/charlie.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIKL8zAzLkR0jKFY2l+QrBjC3Cv4Q3OBUUCX7rQV1jXQe",
fingerprint: "SHA256:b5jdwosbZUx5nsY5zfr/ZCt+4sJYehVBq7Xl/wstJ9U",
},
FixtureKey {
name: "delta",
pem: include_str!("../vectors/delta.key"),
blob: "AAAAC3NzaC1lZDI1NTE5AAAAIIcMK+njeiGqh8THEBPU9MTYrSJ3WHvxv3ZH11JTS/SU",
fingerprint: "SHA256:4MiQ0KGoTEaklHBTlxPDuQkeu0FTyqEbne10K6Z8CKs",
},
];
/// Looks a fixture up by name — `fixture("alpha")` is the canonical
/// client-dial key.
pub fn fixture(name: &str) -> &'static FixtureKey {
FIXTURE_KEYS
.iter()
.find(|k| k.name == name)
.unwrap_or_else(|| panic!("no fixture named {name}"))
}
/// Builds an [`Identity`] for registration (`id` + scopes; the
/// `resources` map is empty — the wire layer only needs the resolved
/// identity, richer ACL shapes are the assembly's).
pub fn identity(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
/// The in-memory [`IdentityProvider`] — registered fingerprints →
/// [`Identity`] resolutions over alkcall's trait verbatim.
///
/// The double the wave-2 auth bridge and dispatch tests resolve
/// against, and the analog of serving `Arc<dyn IdentityProvider>` in
/// [`alkssh::ServeConfig`]: the fingerprint → identity path is ADR-004
/// decision 1's only resolution shape.
pub struct MemIdentityProvider {
identities: parking_lot::RwLock<HashMap<String, Identity>>,
}
impl MemIdentityProvider {
/// An empty registry — nothing resolves until registered
/// (fail-closed: unregistered fingerprints decline).
pub fn new() -> Self {
Self {
identities: parking_lot::RwLock::new(HashMap::new()),
}
}
/// Registers (or replaces) one fingerprint → identity mapping.
pub fn register(&self, fingerprint: &str, identity: Identity) {
self.identities
.write()
.insert(fingerprint.to_string(), identity);
}
/// Removes a registration — the post-resolution-revoke shape.
pub fn revoke(&self, fingerprint: &str) {
self.identities.write().remove(fingerprint);
}
/// The registered fingerprints — inspection for test assertions.
pub fn registered_fingerprints(&self) -> Vec<String> {
let mut keys = self
.identities
.read()
.keys()
.cloned()
.collect::<Vec<String>>();
keys.sort();
keys
}
}
impl Default for MemIdentityProvider {
fn default() -> Self {
Self::new()
}
}
impl IdentityProvider for MemIdentityProvider {
/// The fingerprint path — ADR-004 decision 1's single resolution
/// shape: exact string equality against registered values.
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
self.identities.read().get(fingerprint).cloned()
}
/// The token path resolves nothing in the SSH direction —
/// ADR-004's dispositions are fingerprint-only on this surface
/// (`auth-bridge.md`); the double never invents a token shape.
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
None
}
}
/// The in-memory vault seam — a wrapper over alkvault 0.1's own
/// in-memory construction ([`VaultServiceHandle`], unlocked at
/// construction via `unlock_new`).
///
/// The locked/absent/tampered/mismatch failure postures (POC-2 §3) are
/// exercised *through the real production seams* the source traits
/// call, against this handle — the double exists so tests never touch
/// vault lifecycle decisions themselves: [`TestVault::unlocked`] arms
/// it, `lock` closes it, and the sources keep refusing, never
/// retrying (the crate never unlocks — ADR-005's separation).
#[derive(Clone)]
pub struct TestVault {
handle: VaultServiceHandle,
}
impl TestVault {
/// A freshly armed in-memory vault — unlocked, ready to encrypt.
pub fn unlocked() -> Self {
let handle = VaultServiceHandle::new();
handle
.unlock_new(24)
.expect("in-memory vault arms unlocked");
Self { handle }
}
/// The vault handle the production sources consume — clones share
/// the same vault state (locking through one is visible to all).
pub fn handle(&self) -> VaultServiceHandle {
self.handle.clone()
}
/// Seals one fixture key's PEM into an at-rest record — the
/// ADR-005 inner-format contract (unencrypted OpenSSH PEM in an
/// `EncryptedData` record).
pub fn seal(&self, key: &FixtureKey) -> EncryptedData {
self.encrypt(key.pem)
}
/// Encrypts an arbitrary plaintext (the non-key and tamper rows
/// need it; ADR-005 decision 4's decode-refusal shape).
pub fn encrypt(&self, plaintext: &str) -> EncryptedData {
self.handle
.encrypt(plaintext, alkvault::CURRENT_KEY_VERSION)
.expect("in-memory vault encrypts the plaintext")
}
/// Locks the vault — the locked-vault posture (POC-2 §3, the
/// `KeySourceError::Locked` refusal class).
pub fn lock(&self) {
self.handle.lock();
}
/// The vault's lifecycle state, for posture assertions.
pub fn is_unlocked(&self) -> bool {
self.handle.is_unlocked()
}
}
/// 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
/// an inspectable decision log, for the wave-4 dial tests (and the
/// case-1/case-2 rows of every host-key contract test).
///
/// Verdicts: a rules list of exact `(host, fingerprint)` matches —
/// last matching rule wins — falling back to a configurable
/// default verdict for unlisted pairs. The default of the
/// **default-constructed double is reject** (the crate's fail-closed
/// posture, `OQ-05`); permissive verdicts are the test's explicit
/// choice, mirroring "permissive policy is the assembly's to write".
/// Every decision is recorded with the full [`PresentedHostKey`]
/// context; the log is the inspectable record.
pub struct MemHostKeyPolicy {
default_verdict: bool,
rules: parking_lot::RwLock<Vec<Rule>>,
log: parking_lot::Mutex<Vec<Decision>>,
}
struct Rule {
host: String,
fingerprint: String,
accept: bool,
}
/// One recorded host-key decision — the presented key and the verdict
/// the policy returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
/// The presented key that was decided.
pub presented: PresentedHostKey,
/// The verdict: `true` = accept, `false` = decline.
pub accepted: bool,
}
impl MemHostKeyPolicy {
/// A decision double with the fail-closed default (reject
/// everything without a matching accept rule).
pub fn new() -> Self {
Self::with_default(false)
}
/// A decision double with an explicit default verdict — the
/// case-2 permissive posture (`accepting` doubles) is the test's
/// explicit choice, never a default.
pub fn with_default(default_verdict: bool) -> Self {
Self {
default_verdict,
rules: parking_lot::RwLock::new(Vec::new()),
log: parking_lot::Mutex::new(Vec::new()),
}
}
/// Adds a rule: exact `(host, fingerprint)` → verdict. Last match
/// wins, so later rules override earlier ones.
pub fn rule(&self, host: &str, fingerprint: &str, accept: bool) {
self.rules.write().push(Rule {
host: host.into(),
fingerprint: fingerprint.into(),
accept,
});
}
/// Pins an accept rule for one `(host, fingerprint)` — the case-1
/// trust-anchor shape (hard accept on exact match).
pub fn pin(&self, host: &str, fingerprint: &str) {
self.rule(host, fingerprint, true);
}
/// The decision log — every [`Decision`] in call order.
pub fn decisions(&self) -> Vec<Decision> {
self.log.lock().clone()
}
}
impl Default for MemHostKeyPolicy {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl HostKeyPolicy for MemHostKeyPolicy {
async fn decide(&self, presented: &PresentedHostKey) -> bool {
let rules = self.rules.read();
let verdict = rules
.iter()
.rev()
.find(|r| r.host == presented.host && r.fingerprint == presented.fingerprint)
.map(|r| r.accept)
.unwrap_or(self.default_verdict);
drop(rules);
self.log.lock().push(Decision {
presented: presented.clone(),
accepted: verdict,
});
verdict
}
}