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).
305 lines
11 KiB
Rust
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(
|
|
®istered_fp,
|
|
identity("fixture-alpha", &["relay:connect"]),
|
|
);
|
|
|
|
let resolved = provider
|
|
.resolve_from_fingerprint(®istered_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")
|
|
}
|