//! 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, so 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; /// The in-process fake `sshd` (`w4-dial`), the *client-direction* /// contract vehicle (the dial surface, the wave-4 client chain; its /// own placement/shape decisions live in the module docs: /// `tests/common/sshd_double.rs`). pub mod sshd_double; /// The git-seam doubles (`w3-git-mapping`, `git`-gated: alkssh's /// hand-rolled minimal alkgit quartet per `alkgit/src/backend.rs`'s /// noop shapes; the module's own doc records the decisions: /// `tests/common/git_doubles.rs`). #[cfg(feature = "git")] pub mod git_doubles; /// The session-seam doubles (`w3-tty-session`, `session`-gated; the /// hand-rolled TtyBackend shim + ownership witness; the module's own /// doc records the decisions: `tests/common/tty_doubles.rs`). #[cfg(feature = "session")] pub mod tty_doubles; /// The decline-row tracing capture (`w4-channel-not-held-rows`); the /// mapping binaries' decline-class rows assert the event vocabulary /// through it (the module's own doc records the convention: /// `tests/common/event_capture.rs`). pub mod event_capture; 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/.key`), the public one-line blob /// (`tests/vectors/.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 ` 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` in /// [`alkssh::ServeConfig`]: the fingerprint → identity path is ADR-004 /// decision 1's only resolution shape. pub struct MemIdentityProvider { identities: parking_lot::RwLock>, } 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 { let mut keys = self .identities .read() .keys() .cloned() .collect::>(); 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 { self.identities.read().get(fingerprint).cloned() } /// The token path resolves nothing in the SSH direction, /// since 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 { 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, per ADR-005's separation). #[derive(Clone)] pub struct TestVault { handle: VaultServiceHandle, } impl TestVault { /// A freshly armed in-memory vault, unlocked and 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, } 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). /// /// Verdicts: a rules list of exact `(host, fingerprint)` matches; /// the 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>, log: parking_lot::Mutex>, } 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 { 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 } } /// The leg-2 rows' tracing visibility (`w3-tunnel-listen`): a minimal /// hand-rolled global [`tracing::Subscriber`] printing every event's /// field pairs to stderr, the same hand-rolled convention the /// crate-side vocabulary rows use (no `tracing-subscriber` /// dependency; events fire from spawned tasks on both sides of the /// fabric, so the global subscriber is the only capture shape that /// works). Rows opt in: `trace_global()` once per row. pub fn trace_global() { use std::sync::atomic::{AtomicU64, Ordering}; struct TraceSubscriber(AtomicU64); struct FieldPrinter(u64); impl tracing::field::Visit for FieldPrinter { fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { eprintln!("trace[{}]: {} = {value:?}", self.0, field.name()); } fn record_str(&mut self, field: &tracing::field::Field, value: &str) { eprintln!("trace[{}]: {} = {value:?}", self.0, field.name()); } } impl tracing::Subscriber for TraceSubscriber { fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { true } fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::Id { tracing::Id::from_u64(1) } fn record(&self, _span: &tracing::Id, _values: &tracing::span::Record<'_>) {} fn record_follows_from(&self, _span: &tracing::Id, _follows: &tracing::Id) {} fn event(&self, event: &tracing::Event<'_>) { event.record(&mut FieldPrinter(self.0.fetch_add(1, Ordering::Relaxed))); } fn enter(&self, _span: &tracing::Id) {} fn exit(&self, _span: &tracing::Id) {} fn clone_span(&self, _id: &tracing::Id) -> tracing::Id { tracing::Id::from_u64(1) } } let _ = tracing::subscriber::set_global_default(TraceSubscriber(AtomicU64::new(0))); }