//! 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` 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 = 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::>(); 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") }