Files
alkssh/tests/common/mod.rs
T
glm-5.3-flash 214690b9c2 test(doubles): land the wave-1 in-memory seams + unified ed25519 fixtures
The `w1-test-doubles` task: the in-memory seams every later wave's
contract tests run against, behind their production seams.

- tests/common/mod.rs — the doubles, test-only (nothing reachable from
  lib.rs, no feature): MemIdentityProvider (registered fingerprints ->
  alkcall Identity resolutions over the IdentityProvider trait; token
  path resolves none per ADR-004), TestVault (a wrapper over alkvault
  0.1's shipped in-memory construction — resolving the flagged
  vault-double question: wrap, not re-impl), MemHostKeyPolicy
  (exact (host, fingerprint) rules + explicit default verdict,
  fail-closed by default, inspectable decision log), and the fixture
  table. Module docs record the location/vault/fixture decisions and
  the row/stamp discipline (greppable Contract stamps).
- tests/vectors/ — one unified fixture set: four ssh-keygen-minted
  ed25519 keypairs (committed .key + .pub + fingerprints.txt recorded
  from ssh-keygen -lf, OpenSSH_9.6p1). The previous inline PEM
  constants in src/key_source.rs were rejected by OpenSSH itself
  (russh-only decodable); all keys were re-minted and the overlapping
  vector set retired — one fixture set now serves the parity tests,
  the custody round-trip, and every later auth/mapping test
  (src-unit tests share it via include_str!).
- tests/doubles.rs — the end-to-end rows: register -> resolve,
  seal -> source -> decode (+ locked/absent/tampered postures),
  host-key set in order, policy verdicts honored-and-logged.
- tests/fingerprint_parity.rs and src/key_source.rs tests moved onto
  the shared fixture files; src changes are #cfg(test) only.

Verification: cargo test (33 + 9 + 5 green; --all-features also green),
cargo clippy --all-targets -- -D warnings clean, cargo fmt --check
clean, taskgraph validate ok (12 tasks).
2026-10-28 07:09:17 +00:00

370 lines
14 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)]
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, 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 [`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
}
}