Same treatment for the prose the compiler carries: doc and line comments across src/ and tests/, test assertion and error-message strings (prose surfaces: the tunnel-map registration/establish errors, the key-source decode payload, assertion messages), scripts/matrix.sh comment lines, the fuzz sidecar's comments and README (corpus seeds untouched), and the Cargo.toml comment headers plus the crates.io description. The AGENTS.md no-em-dash rule (convention 14) now holds for the whole tracked tree. Verification: repo-wide git grep is clean (single exempt hit: the verification command's own search pattern), cargo test (18 suites, zero failures, default and per-feature builds via scripts/matrix.sh), clippy --all-targets -D warnings and clippy all-features, fmt --check, doc --no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
306 lines
11 KiB
Rust
306 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 → the
|
|
/// vault's `Zeroizing<String>` return → 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")
|
|
}
|