Files
alkssh/tests/doubles.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

305 lines
11 KiB
Rust

//! The wave-1 doubles' end-to-end rows — the seams exercised through
//! the doubles exactly as the later waves' contract tests will run
//! against them: fingerprint registration resolution, the vault
//! custody round-trip and its failure postures, and the host-key
//! policy verdict path.
//!
//! The row/stamp discipline (see `tests/common/mod.rs`): assertions
//! are named rows stating the property; every row carries its
//! `Contract stamp:` citing the pinning §/ADR.
mod common;
use alkcall::core::auth::{AuthToken, IdentityProvider};
use alkssh::{
HostKeyPolicy, HostKeySource, KeySource, KeySourceError, PresentedHostKey, VaultHostKeySource,
VaultKeySource, fingerprint,
};
use russh::keys::signature::Signer;
use common::{FIXTURE_KEYS, MemHostKeyPolicy, MemIdentityProvider, TestVault, fixture, identity};
/// The registered fingerprint resolves with the registered identity;
/// unregistered fingerprints decline (fail-closed empty registry).
///
/// - *Contract stamp:* ADR-004 decision 1 (fingerprint →
/// `resolve_from_fingerprint` → `Some` = accept; `None` = reject).
/// - *Contract stamp:* ADR-005 context (client fingerprints never
/// involve the vault — the `IdentityProvider` path only).
#[test]
fn mem_identity_provider_registered_fingerprint_resolves() {
let alpha = fixture("alpha");
let registered_fp = fingerprint(&alpha.public_key());
let provider = MemIdentityProvider::new();
provider.register(
&registered_fp,
identity("fixture-alpha", &["relay:connect"]),
);
let resolved = provider
.resolve_from_fingerprint(&registered_fp)
.expect("registered fingerprint resolves");
assert_eq!(resolved.id, "fixture-alpha");
assert_eq!(resolved.scopes, vec!["relay:connect".to_string()]);
assert_eq!(provider.registered_fingerprints(), vec![registered_fp]);
assert!(
provider
.resolve_from_fingerprint(&fingerprint(&fixture("delta").public_key()))
.is_none(),
"unregistered fingerprint declines"
);
}
/// A revocation makes the fingerprint resolve `None` again — the
/// resolution set is inspectable and mutable.
///
/// - *Contract stamp:* ADR-004 decision 1 (`None` → reject is the
/// only disposition for unresolvable fingerprints).
#[test]
fn mem_identity_provider_revocation_resolves_none() {
let alpha = fixture("alpha");
let provider = MemIdentityProvider::new();
provider.register(
alpha.fingerprint,
identity("fixture-alpha", &["relay:connect"]),
);
provider.revoke(alpha.fingerprint);
assert!(
provider
.resolve_from_fingerprint(alpha.fingerprint)
.is_none(),
"revoked fingerprint declines (fail-closed)"
);
assert!(provider.registered_fingerprints().is_empty());
}
/// The token path resolves nothing on the SSH surface — the double
/// never invents a token shape.
///
/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint is the
/// SSH-shaped resolution path); `auth-bridge.md` (`IdentityProvider`
/// is the one SSH-adjacent bridge, fingerprint-shaped).
#[test]
fn mem_identity_provider_token_path_resolves_none() {
let provider = MemIdentityProvider::new();
let token = AuthToken {
raw: b"tok".to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
/// Every fixture key's fingerprint — whether derived via the private
/// PEM or the committed public blob — equals its `ssh-keygen -lf`
/// recorded value. This row keeps the fixture set honest for every
/// consumer; `tests/fingerprint_parity.rs` is the deep parity check.
///
/// - *Contract stamp:* ADR-004 decision 1 (the fingerprint string
/// format is a contract invariant across issuers).
/// - *Contract stamp:* wave-1 test-double convention (fixtures carry
/// recorded `ssh-keygen -lf` fingerprints).
#[test]
fn fixture_keys_carry_ssh_keygen_recorded_fingerprints() {
for key in FIXTURE_KEYS {
let from_private = fingerprint(key.private_key().public_key());
let from_blob = fingerprint(&key.public_key());
assert_eq!(from_private, key.fingerprint, "fixture {}", key.name);
assert_eq!(from_blob, key.fingerprint, "fixture {}", key.name);
assert_eq!(from_private.len(), 50, "SHA256: + 43 unpadded base64 chars");
}
}
/// The full custody round-trip: fixture PEM sealed into an at-rest
/// record through the vault seam, loaded through the production
/// [`VaultKeySource`], decoded by the ADR-005 chain, and verified to
/// be the *same* fixture key (fingerprint parity) that is usable by
/// russh (sign-capable).
///
/// - *Contract stamp:* ADR-005 decision 1 (inner format: unencrypted
/// OpenSSH-PEM in the record).
/// - *Contract stamp:* ADR-005 decision 2 (decrypt-at-use →
/// `Zeroizing` wrap → one-time `decode_secret_key`).
#[tokio::test]
async fn test_vault_seal_then_key_source_decode_round_trips() {
let alpha = fixture("alpha");
let vault = TestVault::unlocked();
let blob = vault.seal(alpha);
let source = VaultKeySource::new(vault.handle(), Some(blob));
let loaded = source.load().await.expect("sealed dial key loads");
assert_eq!(
fingerprint(loaded.public_key()),
alpha.fingerprint,
"the loaded key is the fixture key"
);
assert!(
loaded
.try_sign(b"alkssh w1 doubles usability probe")
.is_ok(),
"the decoded key is sign-capable (russh-usable)"
);
}
/// The POC-2 §3 failure postures ride the real seams: locked vault →
/// `Locked`, absent record → `Absent`, tampered ciphertext →
/// `Decryption` — each a distinct variant, each a refusal, no
/// fallback key shape.
///
/// - *Contract stamp:* ADR-005 decision 4 (failure modes fail closed
/// with distinct error shapes).
/// - *Contract stamp:* `docs/research/poc-2-vault-keys.md` §3 (the
/// failure matrix: locked / absent / tampered / wrong seed).
#[tokio::test]
async fn test_vault_postures_refuse_fail_closed_through_the_source() {
let alpha = fixture("alpha");
let locked = TestVault::unlocked();
let blob = locked.seal(alpha);
locked.lock();
let err = VaultKeySource::new(locked.handle(), Some(blob))
.load()
.await
.expect_err("locked vault refuses");
assert!(matches!(err, KeySourceError::Locked), "got {err:?}");
let absent = TestVault::unlocked();
let err = VaultKeySource::new(absent.handle(), None)
.load()
.await
.expect_err("absent record refuses");
assert!(matches!(err, KeySourceError::Absent), "got {err:?}");
let tampering = TestVault::unlocked();
let mut blob = tampering.seal(alpha);
blob.data = tamper_one_byte(&blob.data);
let err = VaultKeySource::new(tampering.handle(), Some(blob))
.load()
.await
.expect_err("tampered ciphertext refuses");
assert!(matches!(err, KeySourceError::Decryption(_)), "got {err:?}");
}
/// The host-key set loads from the vault seam in registration order —
/// one custody model, both directions (the decrypt-at-construction
/// load path).
///
/// - *Contract stamp:* ADR-005 decision 6 (host keys ride the same
/// model; the client's host *pin* is public config, never vault
/// material).
/// - *Contract stamp:* ADR-005 decision 2 (the same decrypt-and-decode
/// chain as the dial key).
#[tokio::test]
async fn host_key_source_set_loads_from_the_vault_seam_in_order() {
let (bravo, charlie) = (fixture("bravo"), fixture("charlie"));
let vault = TestVault::unlocked();
let blobs = vec![vault.seal(bravo), vault.seal(charlie)];
let source: std::sync::Arc<dyn HostKeySource> =
std::sync::Arc::new(VaultHostKeySource::new(vault.handle(), blobs));
let loaded = source.load().await.expect("host-key set loads");
let fps = loaded
.iter()
.map(|k| fingerprint(k.public_key()))
.collect::<Vec<String>>();
assert_eq!(
fps,
vec![
bravo.fingerprint.to_string(),
charlie.fingerprint.to_string()
]
);
}
/// The policy double's verdicts are honored and its decision log is
/// the inspectable record: pinned exact `(host, fingerprint)` pairs
/// accept, unmatched pairs fall to the default (reject — fail-closed),
/// and every decision is logged with the full presented context.
///
/// - *Contract stamp:* OQ-05 resolution (`host_key.rs` module docs —
/// the crate is policy-blind; verdicts are the policy's).
/// - *Contract stamp:* ADR-005 decision 6 (the host *pin* is public
/// config; exact-string comparison by contract).
#[tokio::test]
async fn mem_host_key_policy_verdicts_are_recorded_and_honored() {
let alpha = fixture("alpha");
let presented = PresentedHostKey {
host: "runner.example".into(),
port: 2222,
fingerprint: alpha.fingerprint.to_string(),
};
let pinned = MemHostKeyPolicy::new();
pinned.pin("runner.example", alpha.fingerprint);
assert!(pinned.decide(&presented).await, "pinned pair accepts");
let delta_fp = PresentedHostKey {
host: "runner.example".into(),
port: 2222,
fingerprint: fixture("delta").fingerprint.to_string(),
};
pinned.pin("runner.example", fixture("delta").fingerprint);
assert!(pinned.decide(&delta_fp).await, "any pinned pair accepts");
let stranger = PresentedHostKey {
host: "stranger.example".into(),
port: 2222,
fingerprint: alpha.fingerprint.to_string(),
};
assert!(
!pinned.decide(&stranger).await,
"default verdict rejects without a matching rule (fail-closed)"
);
let decisions = pinned.decisions();
assert_eq!(decisions.len(), 3);
assert_eq!(decisions[0].presented, presented);
assert!(decisions[0].accepted);
assert!(decisions[1].accepted);
assert_eq!(decisions[2].presented, stranger);
assert!(!decisions[2].accepted);
}
/// A permissive default is the test's explicit choice (never a
/// default), and an explicit rule overrides it — the case-2
/// ephemeral-fleet shape versus a pin-style correction.
///
/// - *Contract stamp:* OQ-05 resolution (`host_key.rs` — there is no
/// accept-all built-in crate option; case-2 policies are the
/// assembly's to write).
/// - *Contract stamp:* ADR-004 decision 6 posture analog
/// (fail-closed by default, permissive only by explicit choice).
#[tokio::test]
async fn mem_host_key_policy_accept_default_is_explicit_and_overrideable() {
let alpha = fixture("alpha");
let presented = PresentedHostKey {
host: "fleet.example".into(),
port: 2200,
fingerprint: alpha.fingerprint.to_string(),
};
let permissive = MemHostKeyPolicy::with_default(true);
assert!(
permissive.decide(&presented).await,
"case-2 permissive posture is the explicit choice"
);
let denying = MemHostKeyPolicy::with_default(true);
denying.rule("fleet.example", alpha.fingerprint, false);
assert!(
!denying.decide(&presented).await,
"an explicit rule overrides the default"
);
}
fn tamper_one_byte(text: &str) -> String {
let mut bytes = text.as_bytes().to_vec();
if bytes[4] == b'A' {
bytes[4] = b'B';
} else {
bytes[4] = b'A';
}
String::from_utf8(bytes).expect("base64 mutation stays text")
}