diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928eb54be..d0e6318f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,22 @@ jobs: sudo apt-get update sudo apt-get -o Acquire::Retries=3 install libsystemd-dev + ## Required by the RDCleanPath credential-injection tests in the testsuite. + ## Keep the version in sync with IRONRDP_AGENT_VERSION in testsuite/src/ironrdp_agent.rs. + - name: Cache ironrdp-agent + id: cache-ironrdp-agent + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ironrdp-agent + ~/.cargo/bin/ironrdp-agent.exe + key: ironrdp-agent-0.1.0-${{ runner.os }} + + - name: Install ironrdp-agent + if: ${{ steps.cache-ironrdp-agent.outputs.cache-hit != 'true' }} + shell: pwsh + run: cargo install ironrdp-agent --version 0.1.0 --locked + - name: Tests run: ./ci/tlk.ps1 test -Platform ${{ matrix.os }} -Architecture ${{ matrix.arch }} -CargoProfile 'dev' shell: pwsh diff --git a/Cargo.lock b/Cargo.lock index a6df6c2b6..765e867bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7524,12 +7524,19 @@ dependencies = [ "fastrand", "futures-util", "ipnetwork", + "ironrdp-connector", + "ironrdp-core 0.2.1", + "ironrdp-pdu", + "ironrdp-tokio", + "kdc", "libsql", "mcp-proxy", "network-scanner", "network-scanner-proto", "nonempty", "picky", + "picky-asn1-der", + "picky-krb", "proxy-socks", "quinn", "rcgen", @@ -7551,6 +7558,7 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", + "x509-cert 0.3.0", ] [[package]] diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 7961358b6..f5272a52b 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -18,22 +18,32 @@ harness = true [dependencies] anyhow = "1.0" assert_cmd = "2.2" +base64 = "0.23" dynosaur = "0.3" escargot = "0.5" expect-test = "1.5" fastrand = "2" +ironrdp-connector = "0.10" +ironrdp-core = { version = "0.2", features = ["std"] } +ironrdp-pdu = { version = "0.9", features = ["std"] } +ironrdp-tokio = "0.10" +kdc = "0.1" +picky-asn1-der = "0.5" +picky-krb = "0.12" +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } serde_json = "1" serde = { version = "1", features = ["derive"] } tempfile = "3" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process", "io-util"] } +tokio-rustls = { version = "0.26", features = ["ring"] } tokio-util = "0.7" typed-builder = "0.21" tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } +x509-cert = { version = "0.3", default-features = false, features = ["std"] } [dev-dependencies] agent-tunnel = { path = "../crates/agent-tunnel", features = ["test-utils"] } agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde"] } -base64 = "0.23" camino = "1" devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } devolutions-gateway = { path = "../devolutions-gateway" } @@ -50,14 +60,12 @@ quinn = "0.11" rcgen = { version = "0.13", features = ["pem", "x509-parser"] } reqwest = { version = "0.12", default-features = false, features = ["json"] } rstest = "0.25" -rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" rustls-pki-types = "1" serde_json = "1" sysevent.path = "../crates/sysevent" tempfile = "3" test-utils.path = "../crates/test-utils" -tokio-rustls = { version = "0.26", features = ["ring"] } uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dev-dependencies] diff --git a/testsuite/src/ironrdp_agent.rs b/testsuite/src/ironrdp_agent.rs new file mode 100644 index 000000000..c155ecae4 --- /dev/null +++ b/testsuite/src/ironrdp_agent.rs @@ -0,0 +1,157 @@ +//! Drives the public `ironrdp-agent` CLI as a real RDP client for process-level Gateway tests. +//! The binary is required (`cargo install ironrdp-agent --version 0.1.0`); set +//! `IRONRDP_AGENT_SKIP=1` to skip agent-based tests instead. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; + +pub const IRONRDP_AGENT_VERSION: &str = "0.1.0"; + +fn ironrdp_agent_bin() -> Option { + if let Ok(path) = std::env::var("IRONRDP_AGENT") { + return Some(PathBuf::from(path)); + } + let name = if cfg!(windows) { + "ironrdp-agent.exe" + } else { + "ironrdp-agent" + }; + if let Ok(home) = std::env::var("CARGO_HOME") { + let path = PathBuf::from(home).join("bin").join(name); + if path.is_file() { + return Some(path); + } + } + let cargo_home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .map(|home| home.join(".cargo").join("bin").join(name)); + if let Some(path) = cargo_home + && path.is_file() + { + return Some(path); + } + if let Ok(path) = std::env::var("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +pub fn require_ironrdp_agent() -> anyhow::Result> { + if std::env::var_os("IRONRDP_AGENT_SKIP").is_some_and(|value| !value.is_empty()) { + eprintln!("skipping ironrdp-agent test: IRONRDP_AGENT_SKIP is set"); + return Ok(None); + } + let Some(bin) = ironrdp_agent_bin() else { + anyhow::bail!( + "ironrdp-agent {IRONRDP_AGENT_VERSION} is required: \ + `cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}`, \ + or set IRONRDP_AGENT_SKIP=1 to skip" + ); + }; + let output = std::process::Command::new(&bin) + .arg("--version") + .output() + .with_context(|| format!("run {} --version", bin.display()))?; + let version = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + version.contains(IRONRDP_AGENT_VERSION), + "expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}", + bin.display() + ); + Ok(Some(bin)) +} + +pub fn ironrdp_agent_endpoint() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(1); + let name = format!( + "ironrdp-e2e-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ); + if cfg!(windows) { + format!(r"\\.\pipe\{name}") + } else { + std::env::temp_dir().join(format!("{name}.sock")).display().to_string() + } +} + +pub async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result { + let child = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "daemon-start"]) + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start ironrdp-agent daemon")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let status = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "status"]) + .output() + .await + .context("ironrdp-agent status")?; + if status.status.success() { + return Ok(child); + } + if Instant::now() >= deadline { + anyhow::bail!( + "ironrdp-agent daemon not ready at {endpoint}: {}", + String::from_utf8_lossy(&status.stderr) + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +pub async fn connect_rdcleanpath( + bin: &Path, + endpoint: &str, + server: &str, + username: &str, + password: &str, + association_token: &str, + http_port: u16, +) -> anyhow::Result { + let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); + tokio::process::Command::new(bin) + .args([ + "--endpoint", + endpoint, + "connect", + "--server", + server, + "--username", + username, + "--password", + password, + "--prop", + &format!("ironrdp_rdcleanpathurl:s:{url}"), + "--prop", + &format!("ironrdp_rdcleanpathtoken:s:{association_token}"), + ]) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("start ironrdp-agent connect") +} + +pub async fn agent_query_logs(bin: &Path, endpoint: &str) -> String { + tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "query-logs"]) + .output() + .await + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default() +} diff --git a/testsuite/src/lib.rs b/testsuite/src/lib.rs index 56ae206ba..605181db1 100644 --- a/testsuite/src/lib.rs +++ b/testsuite/src/lib.rs @@ -6,5 +6,8 @@ pub mod cli; pub mod dgw_config; +pub mod ironrdp_agent; pub mod mcp_client; pub mod mcp_server; +pub mod rdp_injection; +pub mod tls_fixtures; diff --git a/testsuite/src/rdp_injection/INTENT.md b/testsuite/src/rdp_injection/INTENT.md new file mode 100644 index 000000000..71b92a38a --- /dev/null +++ b/testsuite/src/rdp_injection/INTENT.md @@ -0,0 +1,28 @@ +# Intent + +## Senarios +1. NTLM + a. Preflight send a NTLM credential injection request to the Gateway + b. RDP client will send association token with matching JTI in the credential injection request to Gateway. + c. Gateway will correctly recognize this connetion is intended for credential injection + d. RDP client will proceed with TLS + CredSSP + e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server. + f. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials + + +2. Kerberos + a. Preflight send a Kerberos credential injection request to the Gateway including the KDC information. + b. RDP client will send association token with matching JTI in the credential injection request to Gateway. + c. Gateway will correctly recognize this connetion is intended for credential injection + d. RDP client will proceed with TLS + CredSSP, here it will also call KDC Proxy (which is also Gateway) + e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server. + f. Gateway will forward the Kerberos request to the KDC with state driven by the target credentials, and we will assert on that the KDC receives the correct request. + g. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials + +3. Reconnect stability, make sure we could reconnect with the same association token. + +4. Failure behavior + a. Missing or expired provisioning fails at 1.c or 2.c and uses ordinary forwarding. + b. An incorrect target password or unavailable KDC fails at 2.e and must not fall back to ordinary forwarding. + c. Missing KDC information fails before 2.e and must not connect to the RDP server. + diff --git a/testsuite/src/rdp_injection/credssp.rs b/testsuite/src/rdp_injection/credssp.rs new file mode 100644 index 000000000..e124d41b7 --- /dev/null +++ b/testsuite/src/rdp_injection/credssp.rs @@ -0,0 +1,292 @@ +//! Client-side CredSSP against the Gateway: X.224 negotiation, TLS, then an sspi +//! `CredSspClient` whose KDC traffic is resolved over TCP or through `/jet/KdcProxy`. + +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ConnectionConfirm, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use picky_krb::messages::KdcProxyMessage; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpStream; + +use super::mock_kdc::{ObservedKdcReply, ObservedKdcReq, observe_kdc_reply, observe_kdc_req, send_kdc_tcp}; +use super::rdp::{encode_hybrid_cr, encode_pcb}; +use super::tls::{dangerous_tls_connector, peer_public_key}; +use super::{PROXY_PASSWORD, PROXY_USER, SERVICE_HOST}; + +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); + +pub async fn connect_ntlm_client( + gateway_tcp: u16, + association_token: &str, +) -> anyhow::Result> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connect_ntlm_client_inner(gateway_tcp, association_token), + ) + .await + .context("timed out connecting NTLM client to Gateway")? +} + +async fn connect_ntlm_client_inner( + gateway_tcp: u16, + association_token: &str, +) -> anyhow::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect gateway TCP")?; + stream + .write_all(&encode_pcb(association_token)?) + .await + .context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; + stream.flush().await.context("flush CR")?; + + let mut framed = TokioFramed::new(stream); + let (_, confirm) = framed.read_pdu().await.context("read X.224 CC")?; + let confirm: X224 = ironrdp_core::decode(&confirm).context("decode X.224 CC")?; + anyhow::ensure!( + matches!(confirm.0, ConnectionConfirm::Response { protocol, .. } if protocol.contains(SecurityProtocol::HYBRID)), + "gateway did not confirm CredSSP: {confirm:?}" + ); + + let tcp = framed.into_inner_no_leftover(); + let connector = dangerous_tls_connector(); + let server_name = ServerName::try_from("localhost").map_err(|error| anyhow::anyhow!("{error}"))?; + connector.connect(server_name, tcp).await.context("TLS to gateway") +} + +pub async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, false, None, None).await +} + +pub async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, true, None, None).await +} + +pub async fn complete_client_credssp( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + complete_client_credssp_inner(tls, username, kdc_proxy_url, raw_ntlm, proxy_replies, proxy_requests), + ) + .await + .context("timed out completing client CredSSP")? +} + +async fn complete_client_credssp_inner( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; + use sspi::ntlm::NtlmConfig; + + let public_key = peer_public_key(&tls)?; + let mut framed = TokioFramed::new(tls); + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(username).context("parse client username")?, + password: PROXY_PASSWORD.to_owned().into(), + }; + let client_mode = if let Some(kdc_url) = kdc_proxy_url { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(sspi::KerberosConfig { + kdc_url: Some(kdc_url.parse().context("parse KDC proxy URL")?), + client_computer_name: "cred-injection-e2e".to_owned(), + }), + Some("kerberos,!ntlm".to_owned()), + "cred-injection-e2e".to_owned(), + )) + } else if raw_ntlm { + // Gateway NTLM injection uses ServerMode::Ntlm, which rejects SPNEGO. + ClientMode::Ntlm(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }) + } else { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "cred-injection-e2e".to_owned(), + )) + }; + let mut client = CredSspClient::new( + public_key, + identity.into(), + CredSspMode::WithCredentials, + client_mode, + format!("TERMSRV/{SERVICE_HOST}"), + ) + .context("init CredSSP client")?; + + let mut ts_request = TsRequest::default(); + let mut buf = ironrdp_pdu::WriteBuf::new(); + let hint = TsRequestHint; + + for _ in 0..8 { + let client_state = { + let mut generator = client.process(std::mem::take(&mut ts_request)); + resolve_sspi_client(&mut generator, proxy_replies, proxy_requests).await? + }; + let (outbound, finished) = match client_state { + ClientState::ReplyNeeded(request) => (request, false), + ClientState::FinalMessage(request) => (request, true), + }; + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode client TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write client CredSSP")?; + if finished { + return Ok(()); + } + let pdu = framed.read_by_hint(&hint).await.context("read server CredSSP")?; + ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; + } + + anyhow::bail!("CredSSP exceeded 8 round trips") +} + +async fn resolve_sspi_client( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + sspi::Result, + >, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = match request.url.scheme() { + "tcp" | "udp" => send_kdc_tcp(&request).await?, + "http" | "https" => send_kdc_http(&request, proxy_replies, proxy_requests).await?, + other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), + }; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => { + break result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")); + } + } + } +} + +async fn send_kdc_http( + request: &sspi::generator::NetworkRequest, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result> { + let host = request.url.host_str().context("KDC proxy host")?; + let port = request.url.port_or_known_default().unwrap_or(80); + let path = if request.url.query().is_some() { + format!("{}?{}", request.url.path(), request.url.query().unwrap_or_default()) + } else { + request.url.path().to_owned() + }; + let mut stream = TcpStream::connect((host, port)).await.context("connect KDC proxy")?; + let header = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}:{port}\r\n\ + Content-Type: application/octet-stream\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + request.data.len() + ); + stream + .write_all(header.as_bytes()) + .await + .context("write KDC proxy headers")?; + stream.write_all(&request.data).await.context("write KDC proxy body")?; + stream.flush().await.context("flush KDC proxy")?; + if let Ok(message) = KdcProxyMessage::from_raw(&request.data) + && let Some(log) = proxy_requests + { + let kerb = message.kerb_message.0.0.get(4..).unwrap_or(&message.kerb_message.0.0); + log.lock().expect("proxy request mutex").push(observe_kdc_req(kerb)); + } + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read KDC proxy status")?; + anyhow::ensure!( + status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200"), + "KDC proxy HTTP status was {status_line:?}" + ); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read KDC proxy header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse KDC proxy Content-Length")?); + } + } + + let buf = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) + .await + .context("read KDC proxy body")?; + buf + } else { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) + .await + .context("read KDC proxy eof body")?; + buf + }; + if let Ok(message) = KdcProxyMessage::from_raw(&buf) + && let Some(log) = proxy_replies + { + log.lock() + .expect("proxy reply mutex") + .push(observe_kdc_reply(&message.kerb_message.0.0)); + } + Ok(buf) +} + +#[derive(Debug)] +pub(crate) struct TsRequestHint; + +impl ironrdp_pdu::PduHint for TsRequestHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { + match sspi::credssp::TsRequest::read_length(bytes) { + Ok(length) => Ok(Some((true, length))), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None), + Err(error) => Err(ironrdp_core::other_err!("TsRequestHint", source: error)), + } + } +} diff --git a/testsuite/src/rdp_injection/gateway.rs b/testsuite/src/rdp_injection/gateway.rs new file mode 100644 index 000000000..7633358cd --- /dev/null +++ b/testsuite/src/rdp_injection/gateway.rs @@ -0,0 +1,116 @@ +//! Runs a real Gateway child process and exposes its logs for assertions. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::Child; + +use crate::cli::{dgw_tokio_cmd, wait_for_tcp_port}; +use crate::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; + +fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +pub struct LogBuffer(Arc>); + +impl LogBuffer { + fn new() -> Self { + Self(Arc::new(Mutex::new(String::new()))) + } + + pub fn snapshot(&self) -> String { + strip_ansi(&self.0.lock().expect("log mutex")) + } + + pub async fn wait_contains(&self, needle: &str) -> anyhow::Result { + self.wait_count(needle, 1).await + } + + pub async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = self.snapshot(); + if snapshot.matches(needle).count() >= count { + return Ok(snapshot); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for {count} occurrence(s) of {needle:?}; logs:\n{snapshot}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +pub struct GatewayProc { + pub config: DgwConfigHandle, + pub process: Child, + pub logs: LogBuffer, +} + +impl GatewayProc { + pub async fn start() -> anyhow::Result { + let config = DgwConfig::builder() + .disable_token_validation(true) + .verbosity_profile(VerbosityProfile::DEBUG) + .build() + .init() + .context("init gateway config")?; + + let mut process = dgw_tokio_cmd() + .env("DGATEWAY_CONFIG_PATH", config.config_dir()) + .env("RUST_LOG", "devolutions_gateway=debug") + .env("NO_COLOR", "1") + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("start Devolutions Gateway")?; + + let logs = LogBuffer::new(); + spawn_stdio_collector(process.stdout.take(), Arc::clone(&logs.0)); + spawn_stdio_collector(process.stderr.take(), Arc::clone(&logs.0)); + + wait_for_tcp_port(config.http_port()) + .await + .context("wait for gateway HTTP port")?; + + Ok(Self { config, process, logs }) + } +} + +fn spawn_stdio_collector(stream: Option, logs: Arc>) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + let Some(stream) = stream else { + return; + }; + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => logs.lock().expect("log mutex").push_str(&line), + } + } + }); +} diff --git a/testsuite/src/rdp_injection/mock_kdc.rs b/testsuite/src/rdp_injection/mock_kdc.rs new file mode 100644 index 000000000..d64384825 --- /dev/null +++ b/testsuite/src/rdp_injection/mock_kdc.rs @@ -0,0 +1,242 @@ +//! Loopback Kerberos KDCs: a real one backed by the `kdc` crate (the same crate the Gateway +//! embeds for its synthetic KDC) and a refusing one for fail-closed coverage. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use picky_krb::data_types::PrincipalName; +use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; + +use super::{KRBTGT_KEY, REALM, SERVICE_HOST, TARGET_PASSWORD, TERMSRV_KEY}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ObservedKdcReq { + As { cname: String, realm: String }, + Tgs { sname: Vec, realm: String }, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ObservedKdcReply { + AsRep, + TgsRep, + KrbError, + Other, +} + +pub struct MockKdc { + port: u16, + exchanges: Arc, + requests: Arc>>, +} + +impl MockKdc { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; + let port = listener.local_addr().context("mock KDC local_addr")?.port(); + let exchanges = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let exchanges_task = Arc::clone(&exchanges); + let requests_task = Arc::clone(&requests); + let config = kdc_config(); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let config = config.clone(); + let exchanges = Arc::clone(&exchanges_task); + let requests = Arc::clone(&requests_task); + tokio::spawn(async move { + match serve_kdc_exchange(stream, &config, &requests).await { + Ok(()) => { + exchanges.fetch_add(1, Ordering::SeqCst); + } + Err(error) => eprintln!("mock KDC exchange failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + exchanges, + requests, + }) + } + + pub fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + pub fn exchanges(&self) -> usize { + self.exchanges.load(Ordering::SeqCst) + } + + pub fn requests(&self) -> Vec { + self.requests.lock().expect("kdc request mutex").clone() + } +} + +fn kdc_config() -> kdc::config::KerberosServer { + let username = format!("administrator@{REALM}"); + kdc::config::KerberosServer { + realm: REALM.to_owned(), + users: vec![kdc::config::DomainUser { + username, + password: TARGET_PASSWORD.to_owned(), + salt: format!("{}administrator", REALM.to_ascii_uppercase()), + }], + max_time_skew: 300, + krbtgt_key: KRBTGT_KEY.to_vec(), + ticket_decryption_key: Some(TERMSRV_KEY.to_vec()), + service_user: None, + } +} + +async fn serve_kdc_exchange( + mut stream: TcpStream, + config: &kdc::config::KerberosServer, + requests: &Mutex>, +) -> anyhow::Result<()> { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + + requests.lock().expect("kdc request mutex").push(observe_kdc_req(&body)); + + let mut raw = Vec::with_capacity(4 + len); + raw.extend_from_slice(&len_buf); + raw.extend_from_slice(&body); + + let request = KdcProxyMessage::from_raw_kerb_message(&raw).context("wrap KDC TCP payload")?; + let reply = kdc::handle_kdc_proxy_message(request, config, SERVICE_HOST).context("handle KDC message")?; + stream + .write_all(&reply.kerb_message.0.0) + .await + .context("write KDC reply")?; + Ok(()) +} + +fn principal_strings(name: &PrincipalName) -> Vec { + name.name_string.0.0.iter().map(|part| part.0.to_string()).collect() +} + +pub(crate) fn observe_kdc_req(body: &[u8]) -> ObservedKdcReq { + if let Ok(as_req) = picky_asn1_der::from_bytes::(body) { + let req = &as_req.0.req_body.0; + let cname = req + .cname + .0 + .as_ref() + .map(|name| principal_strings(&name.0).join("/")) + .unwrap_or_default(); + return ObservedKdcReq::As { + cname, + realm: req.realm.0.to_string(), + }; + } + if let Ok(tgs_req) = picky_asn1_der::from_bytes::(body) { + let req = &tgs_req.0.req_body.0; + let sname = req + .sname + .0 + .as_ref() + .map(|name| principal_strings(&name.0)) + .unwrap_or_default(); + return ObservedKdcReq::Tgs { + sname, + realm: req.realm.0.to_string(), + }; + } + ObservedKdcReq::Other +} + +pub(crate) fn observe_kdc_reply(body: &[u8]) -> ObservedKdcReply { + let krb = body.get(4..).unwrap_or(body); + if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::AsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::TgsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::KrbError + } else { + ObservedKdcReply::Other + } +} + +pub async fn send_kdc_tcp(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { + let host = request.url.host_str().context("KDC host")?; + let port = request.url.port().unwrap_or(88); + let mut stream = TcpStream::connect((host, port)).await.context("connect mock KDC")?; + stream.write_all(&request.data).await.context("write KDC request")?; + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + let mut reply = Vec::with_capacity(4 + len); + reply.extend_from_slice(&len_buf); + reply.extend_from_slice(&body); + Ok(reply) +} + +/// Accepts TCP and never answers, so ticket fetching hangs until the caller gives up. +pub struct RefusingKdc { + port: u16, + accepted: Arc, +} + +impl RefusingKdc { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind refusing KDC")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + pub fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} + +pub fn assert_target_kdc_as_and_tgs(kdc: &MockKdc) -> anyhow::Result<()> { + let reqs = kdc.requests(); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see AS-REQ cname=administrator realm={REALM}; requests={reqs:?}" + ); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see TGS-REQ sname=TERMSRV/{SERVICE_HOST} realm={REALM}; requests={reqs:?}" + ); + Ok(()) +} diff --git a/testsuite/src/rdp_injection/mock_rdp.rs b/testsuite/src/rdp_injection/mock_rdp.rs new file mode 100644 index 000000000..4660f6a0e --- /dev/null +++ b/testsuite/src/rdp_injection/mock_rdp.rs @@ -0,0 +1,336 @@ +//! Fake RDP server that completes CredSSP as the target: X.224 accept, TLS, then an +//! sspi `CredSspServer` in Kerberos or NTLM mode. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest, ResponseFlags, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use tokio::net::{TcpListener, TcpStream}; + +use super::credssp::TsRequestHint; +use super::mock_kdc::send_kdc_tcp; +use super::rdp::record_cookie; +use super::tls::{install_crypto_provider, server_public_key, tls_acceptor}; +use super::{KERBEROS_TARGET_USER, REALM, SERVICE_HOST, TARGET_PASSWORD, TARGET_USER, TERMSRV_KEY}; + +#[derive(Clone)] +enum MockRdpMode { + Kerberos { kdc_url: Option }, + Ntlm, +} + +pub struct MockRdp { + pub port: u16, + credssp_ok: Arc, + finished_account: Arc>>, + cookies: Arc>>, +} + +impl MockRdp { + pub async fn start_kerberos(kdc_url: String) -> anyhow::Result { + Self::start(MockRdpMode::Kerberos { kdc_url: Some(kdc_url) }).await + } + + pub async fn start_ntlm() -> anyhow::Result { + Self::start(MockRdpMode::Ntlm).await + } + + async fn start(mode: MockRdpMode) -> anyhow::Result { + install_crypto_provider(); + // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. + let listener = match TcpListener::bind("[::]:0").await { + Ok(listener) => listener, + Err(_) => TcpListener::bind("127.0.0.1:0").await.context("bind mock RDP")?, + }; + let port = listener.local_addr().context("mock RDP local_addr")?.port(); + let credssp_ok = Arc::new(AtomicBool::new(false)); + let finished_account = Arc::new(Mutex::new(None)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let credssp_ok_task = Arc::clone(&credssp_ok); + let finished_account_task = Arc::clone(&finished_account); + let cookies_task = Arc::clone(&cookies); + let acceptor = tls_acceptor()?; + let public_key = server_public_key()?; + + tokio::spawn(async move { + loop { + let Ok((stream, peer)) = listener.accept().await else { + break; + }; + let acceptor = acceptor.clone(); + let public_key = public_key.clone(); + let credssp_ok = Arc::clone(&credssp_ok_task); + let finished_account = Arc::clone(&finished_account_task); + let cookies = Arc::clone(&cookies_task); + let mode = mode.clone(); + tokio::spawn(async move { + let result = match &mode { + MockRdpMode::Kerberos { kdc_url } => { + accept_kerberos_rdp( + stream, + peer, + acceptor, + public_key, + kdc_url.as_deref(), + &cookies, + &finished_account, + ) + .await + } + MockRdpMode::Ntlm => { + accept_ntlm_rdp(stream, peer, acceptor, public_key, &cookies, &finished_account).await + } + }; + match result { + Ok(()) => credssp_ok.store(true, Ordering::SeqCst), + Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + credssp_ok, + finished_account, + cookies, + }) + } + + pub fn credssp_ok(&self) -> bool { + self.credssp_ok.load(Ordering::SeqCst) + } + + pub fn finished_account(&self) -> Option { + self.finished_account.lock().expect("finished account mutex").clone() + } + + pub fn cookies(&self) -> Vec { + self.cookies.lock().expect("cookie mutex").clone() + } + + pub async fn wait_credssp(&self) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if self.credssp_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for Kerberos CredSSP on mock RDP"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +async fn accept_kerberos_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + kdc_url: Option<&str>, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(KERBEROS_TARGET_USER).context("parse target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let kerberos_config = sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: kdc_url + .map(|url| url.parse()) + .transpose() + .context("parse mock KDC URL")?, + client_computer_name: peer.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", SERVICE_HOST], + Some(sspi::CredentialsBuffers::AuthIdentity( + sspi::AuthIdentityBuffers::from_utf8(identity.username.account_name(), REALM, TARGET_PASSWORD), + )), + Duration::from_secs(300), + Some(sspi::Secret::new(TERMSRV_KEY.to_vec())), + ) + .context("Kerberos server properties")?, + }; + + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(kerberos_config), + Some("kerberos,!ntlm".to_owned()), + peer.to_string(), + )), + ) + .context("init Kerberos-only CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") +} + +async fn accept_ntlm_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(TARGET_USER).context("parse NTLM target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Ntlm(sspi::ntlm::NtlmConfig { + client_computer_name: Some(peer.to_string()), + }), + ) + .context("init NTLM CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP NTLM CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") +} + +async fn resolve_sspi_server( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + Result, + >, +) -> Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = send_kdc_tcp(&request) + .await + .map_err(|error| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::NoAuthenticatingAuthority, error), + })?; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => break result, + } + } +} + +struct IdentityProxy(sspi::AuthIdentity); + +impl sspi::credssp::CredentialsProxy for IdentityProxy { + type AuthenticationData = sspi::AuthIdentity; + + fn auth_data_by_user(&mut self, username: &sspi::Username) -> std::io::Result { + if username.account_name() != self.0.username.account_name() { + return Err(std::io::Error::other("invalid username")); + } + let mut data = self.0.clone(); + data.username = username.clone(); + Ok(data) + } + + fn auth_data(&mut self) -> Result, std::io::Error> { + Ok(vec![self.0.clone()]) + } +} diff --git a/testsuite/src/rdp_injection/mod.rs b/testsuite/src/rdp_injection/mod.rs new file mode 100644 index 000000000..0e2f884b6 --- /dev/null +++ b/testsuite/src/rdp_injection/mod.rs @@ -0,0 +1,37 @@ +//! Process-level helpers for RDP credential-injection tests. +//! +//! A real Gateway runs as a child process; loopback peers stand in for the destination RDP +//! server and the Kerberos KDC. Tests observe injection from Gateway logs, from the rewritten +//! mstshash cookie arriving at the fake target, and from the Kerberos exchanges recorded by +//! the mock KDC. + +pub mod credssp; +pub mod gateway; +pub mod mock_kdc; +pub mod mock_rdp; +pub mod preflight; +pub mod rdp; +pub mod tls; +pub mod tokens; + +pub const CLIENT_COOKIE: &str = "client-cookie-user"; +pub const TARGET_USER: &str = "injected-target-user"; +pub const PROXY_USER: &str = "injected-proxy-user"; +pub const PROXY_PASSWORD: &str = "proxy-secret"; +pub const TARGET_PASSWORD: &str = "target-secret"; +pub const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; +pub const PROXY_KERBEROS_USER: &str = "injected-proxy-user@example.invalid"; + +pub const REALM: &str = "EXAMPLE.INVALID"; +// sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. +pub const SERVICE_HOST: &str = "localhost"; +pub const KRBTGT_KEY: [u8; 32] = [0x11; 32]; +pub const TERMSRV_KEY: [u8; 32] = [0x22; 32]; + +pub const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; +pub const FORWARD_LOG: &str = "Upstream forwarding"; +pub const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +pub const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; +pub const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; +pub const RDCLEANPATH_INJECT_LOG: &str = "Switching to RdpProxy for credential injection (WebSocket)"; +pub const RDCLEANPATH_FORWARD_LOG: &str = "RDP-TLS forwarding (RDCleanPath)"; diff --git a/testsuite/src/rdp_injection/preflight.rs b/testsuite/src/rdp_injection/preflight.rs new file mode 100644 index 000000000..ffb9a715c --- /dev/null +++ b/testsuite/src/rdp_injection/preflight.rs @@ -0,0 +1,144 @@ +//! Provisions credentials over `/jet/preflight` the way DVLS does. + +use anyhow::Context as _; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpStream; + +use super::tokens::{next_id, preflight_scope_token}; +use super::{PROXY_PASSWORD, PROXY_USER, TARGET_PASSWORD}; + +pub async fn post_preflight( + http_port: u16, + provisioning_operations: serde_json::Value, +) -> anyhow::Result { + let preflight_token = preflight_scope_token()?; + let body = serde_json::to_string(&provisioning_operations).context("serialize preflight body")?; + let request = format!( + "POST /jet/preflight HTTP/1.1\r\n\ + Host: 127.0.0.1:{http_port}\r\n\ + Content-Type: application/json\r\n\ + Authorization: Bearer {preflight_token}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let mut stream = TcpStream::connect(("127.0.0.1", http_port)) + .await + .context("connect to gateway HTTP")?; + stream.write_all(request.as_bytes()).await.context("write preflight")?; + stream.flush().await.context("flush preflight")?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read preflight status")?; + anyhow::ensure!(status_line.contains("200"), "preflight HTTP status was {status_line:?}"); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read preflight header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse Content-Length")?); + } + } + + let response_body = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).await.context("read preflight body")?; + String::from_utf8(buf).context("preflight body utf-8")? + } else { + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .await + .context("read preflight eof body")?; + buf + }; + + let json: serde_json::Value = + serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; + let outputs = json.as_array().context("preflight response is not an array")?; + // Re-provisioning the same JTI emits an info alert, then still acks. + let mut acked = 0usize; + for output in outputs { + match output["kind"].as_str() { + Some("ack") => acked += 1, + Some("alert") if output["alert_status"] == "info" => {} + _ => anyhow::bail!("preflight operation was not ack: {output}"), + } + } + anyhow::ensure!(acked > 0, "preflight returned no ack: {json}"); + Ok(json) +} + +pub async fn provision_credentials( + http_port: u16, + association_token: &str, + target_username: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + provision_mapping( + http_port, + association_token, + PROXY_USER, + target_username, + TARGET_PASSWORD, + time_to_live, + krb_kdc, + ) + .await +} + +pub async fn provision_mapping( + http_port: u16, + association_token: &str, + proxy_username: &str, + target_username: &str, + target_password: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + let mut provisioning_operations = vec![serde_json::json!({ + "id": next_id(), + "kind": "provision-credentials", + "token": association_token, + "proxy_credential": { + "kind": "username-password", + "username": proxy_username, + "password": PROXY_PASSWORD + }, + "target_credential": { + "kind": "username-password", + "username": target_username, + "password": target_password + }, + "time_to_live": time_to_live + })]; + + if let Some(krb_kdc) = krb_kdc { + provisioning_operations.push(serde_json::json!({ + "id": next_id(), + "kind": "provision-connection-options", + "token": association_token, + "connection_options": { "krb_kdc": krb_kdc }, + "time_to_live": time_to_live + })); + } + + post_preflight(http_port, serde_json::Value::Array(provisioning_operations)).await?; + Ok(()) +} diff --git a/testsuite/src/rdp_injection/rdp.rs b/testsuite/src/rdp_injection/rdp.rs new file mode 100644 index 000000000..4ce5655bf --- /dev/null +++ b/testsuite/src/rdp_injection/rdp.rs @@ -0,0 +1,168 @@ +//! Minimal RDP protocol pieces: the client's opening bytes and loopback targets. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData, RequestFlags, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::TokioFramed; +use tokio::io::AsyncWriteExt as _; +use tokio::net::{TcpListener, TcpStream}; + +use super::CLIENT_COOKIE; + +pub fn encode_pcb(association_token: &str) -> anyhow::Result> { + let pcb = ironrdp_pdu::pcb::PreconnectionBlob { + version: ironrdp_pdu::pcb::PcbVersion::V2, + id: 0, + v2_payload: Some(association_token.to_owned()), + }; + ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") +} + +pub fn encode_connection_request(cookie: &str) -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(cookie.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode X.224 connection request") +} + +pub fn encode_hybrid_cr() -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(CLIENT_COOKIE.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode hybrid CR") +} + +pub fn decode_x224_cookie(payload: &[u8]) -> Option { + let cr: X224 = ironrdp_core::decode(payload).ok()?; + match cr.0.nego_data { + Some(NegoRequestData::Cookie(cookie)) => Some(cookie.0), + _ => None, + } +} + +pub(crate) fn record_cookie(cr: &X224, cookies: &Mutex>) { + if let Some(NegoRequestData::Cookie(cookie)) = &cr.0.nego_data { + cookies.lock().expect("cookie mutex").push(cookie.0.clone()); + } +} + +pub async fn connect_rdp_client(gateway_tcp: u16, association_token: &str) -> anyhow::Result { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect to gateway TCP")?; + stream + .write_all(&encode_pcb(association_token)?) + .await + .context("write preconnection blob")?; + stream + .write_all(&encode_connection_request(CLIENT_COOKIE)?) + .await + .context("write connection request")?; + stream.flush().await.context("flush RDP client")?; + Ok(stream) +} + +/// Stands in for the destination RDP server and records the X.224 Connection Request cookie +/// the proxy forwards. +pub struct FakeRdpTarget { + pub port: u16, + accepted: Arc, + cookies: Arc>>, +} + +impl FakeRdpTarget { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; + let port = listener.local_addr().context("fake RDP local_addr")?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let accepted_task = Arc::clone(&accepted); + let cookies_task = Arc::clone(&cookies); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + let cookies = Arc::clone(&cookies_task); + tokio::spawn(async move { + let mut framed = TokioFramed::new(stream); + // CredSSP cert generation can delay the rewritten X.224 CR. + if let Ok(Ok((_, request))) = tokio::time::timeout(Duration::from_secs(30), framed.read_pdu()).await + && let Some(cookie) = decode_x224_cookie(&request) + { + cookies.lock().expect("cookie mutex").push(cookie); + } + // Keep the accepted socket open so the proxy can finish writing the CR. + tokio::time::sleep(Duration::from_secs(30)).await; + }); + } + }); + + Ok(Self { + port, + accepted, + cookies, + }) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } + + pub async fn wait_cookies(&self, count: usize) -> anyhow::Result> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let cookies = self.cookies.lock().expect("cookie mutex"); + if cookies.len() >= count { + return Ok(cookies.clone()); + } + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {count} decoded X.224 cookie(s); accepted={}", + self.accepted() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +/// Accepts connections and never speaks; proves the proxy did (or did not) dial the target. +pub struct FakeClosedTarget { + pub port: u16, + accepted: Arc, +} + +impl FakeClosedTarget { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} diff --git a/testsuite/src/rdp_injection/tls.rs b/testsuite/src/rdp_injection/tls.rs new file mode 100644 index 000000000..07afe4f3c --- /dev/null +++ b/testsuite/src/rdp_injection/tls.rs @@ -0,0 +1,105 @@ +//! TLS plumbing for the fake RDP server and the test client. + +use std::sync::Arc; + +use anyhow::Context as _; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ClientConfig, ServerConfig}; +use tokio::net::TcpStream; +use x509_cert::der::Decode as _; + +use crate::tls_fixtures::{CERT_PEM, KEY_PEM}; + +pub fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +pub fn tls_acceptor() -> anyhow::Result { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse cert PEM")?; + let key = PrivateKeyDer::from_pem_slice(KEY_PEM.as_bytes()).context("parse key PEM")?; + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .context("TLS server config")?; + Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) +} + +pub fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { + let mut config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + config.resumption = rustls::client::Resumption::disabled(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +pub fn peer_public_key(tls: &tokio_rustls::client::TlsStream) -> anyhow::Result> { + let cert = tls + .get_ref() + .1 + .peer_certificates() + .and_then(|certs| certs.first()) + .context("gateway TLS certificate missing")?; + extract_public_key(cert) +} + +pub fn server_public_key() -> anyhow::Result> { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse mock RDP cert")?; + extract_public_key(&cert) +} + +fn extract_public_key(cert: &CertificateDer<'_>) -> anyhow::Result> { + let cert = x509_cert::Certificate::from_der(cert.as_ref()).context("parse X509")?; + let public_key = cert + .tbs_certificate() + .subject_public_key_info() + .subject_public_key + .as_bytes() + .context("unaligned subject public key")? + .to_owned(); + Ok(public_key) +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::ED25519, + ] + } +} diff --git a/testsuite/src/rdp_injection/tokens.rs b/testsuite/src/rdp_injection/tokens.rs new file mode 100644 index 000000000..3d52ca400 --- /dev/null +++ b/testsuite/src/rdp_injection/tokens.rs @@ -0,0 +1,98 @@ +//! Unsigned JWTs for suites running with `disable_token_validation`. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::Context as _; +use base64::Engine as _; + +use super::SERVICE_HOST; + +pub fn next_id() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(1); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("00000000-0000-4000-a000-{n:012x}") +} + +pub fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); + let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); + Ok(format!("{header}.{payload}.ZHVtbXlfc2lnbmF0dXJl")) +} + +pub fn preflight_scope_token() -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), + serde_json::json!({ + "scope": "gateway.preflight", + "exp": 9_999_999_999i64, + "jti": next_id(), + }), + ) +} + +pub fn association_token( + association_jti: &str, + association_id: &str, + dest_port: u16, + jet_reuse: u32, +) -> anyhow::Result { + association_claims( + association_jti, + association_id, + format!("127.0.0.1:{dest_port}"), + jet_reuse, + ) +} + +pub fn association_token_for_host( + association_jti: &str, + association_id: &str, + dest_port: u16, + jet_reuse: u32, +) -> anyhow::Result { + association_claims( + association_jti, + association_id, + format!("{SERVICE_HOST}:{dest_port}"), + jet_reuse, + ) +} + +fn association_claims( + association_jti: &str, + association_id: &str, + dst_hst: String, + jet_reuse: u32, +) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": dst_hst, + "exp": 9_999_999_999i64, + "jet_aid": association_id, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": association_jti, + "nbf": 0, + }), + ) +} + +pub fn kdc_inject_token(association_jti: &str) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"KDC"}), + serde_json::json!({ + "exp": 9_999_999_999i64, + "jet_cred_id": association_jti, + "jti": next_id(), + }), + ) +} + +pub fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { + let kdc_token = kdc_inject_token(association_jti)?; + Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{kdc_token}")) +} diff --git a/testsuite/src/tls_fixtures.rs b/testsuite/src/tls_fixtures.rs new file mode 100644 index 000000000..1bfa7bdeb --- /dev/null +++ b/testsuite/src/tls_fixtures.rs @@ -0,0 +1,52 @@ +//! Shared localhost TLS material for process-level Gateway tests. + +/// Self-signed certificate for localhost (valid for 100 years). +pub const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx +MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj +cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 +RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc +34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 +pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh +ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud +DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF +3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB +i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH +05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R +IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ +RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 +x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 +/v4eYM0cCHN0h+rynSor +-----END CERTIFICATE-----"#; + +/// Private key for the self-signed certificate. +pub const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c +QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf +m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW +a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv +Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg +UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j +rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ +2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E +Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N +fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD +Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B +MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L +c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW +jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH +c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G +ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo +Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt +N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ +STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO +udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC +AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q +aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En ++XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL +WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS +KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR +bQGM8UrNQvWQ+tutRmp7PM6VuQ== +-----END PRIVATE KEY-----"#; diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs new file mode 100644 index 000000000..90c62187f --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -0,0 +1,285 @@ +//! Process-level tests for RDP credential injection, reconnect, and fail-closed routing. +//! +//! These tests start a real Gateway, provision credentials over `/jet/preflight` the way DVLS +//! does, then connect to the TCP listener with an RDP preconnection blob. A loopback peer stands +//! in for the destination RDP server and records the X.224 Connection Request the proxy forwards. +//! Injection is observed from Gateway logs and from the rewritten mstshash cookie. CredSSP is not +//! completed: the contract under test is checkout, reconnect, and fail-closed routing. + +use testsuite::rdp_injection::gateway::GatewayProc; +use testsuite::rdp_injection::preflight::provision_credentials; +use testsuite::rdp_injection::rdp::{FakeRdpTarget, connect_rdp_client}; +use testsuite::rdp_injection::tokens::{association_token, next_id}; +use testsuite::rdp_injection::{ + CLIENT_COOKIE, FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, PUBLISHED_KDC_LOG, REGISTERED_KDC_LOG, TARGET_USER, +}; +use tokio::time::Duration; + +#[tokio::test] +async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "injection must not fall back to ordinary forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![TARGET_USER], + "decoded X.224 cookie must be the injected user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn reconnect_same_association_token_still_injects() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; + + let first = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + gateway.logs.wait_count(INJECT_LOG, 1).await?; + target.wait_cookies(1).await?; + drop(first); + + let _second = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches(INJECT_LOG).count(), + 2, + "reconnect must inject exactly twice; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "reconnect must keep injecting, not ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(2).await?; + assert_eq!( + cookies, + vec![TARGET_USER, TARGET_USER], + "both decoded X.224 cookies must be the injected user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn expired_staging_uses_ordinary_forward() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 1, None).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "evicted staging credentials must not inject; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "absent mapping should ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + // Keep overlapping reconnect sockets so the same-generation KDC lease stays live. + let _first = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 1).await?; + assert!( + logs.contains("kerberos=true"), + "expected Kerberos injection; logs:\n{logs}" + ); + gateway.logs.wait_count(PUBLISHED_KDC_LOG, 1).await?; + + let _second = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 2, + "same generation reconnect should still inject Kerberos; logs:\n{logs}" + ); + gateway.logs.wait_count(REGISTERED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 1, + "same provisioning generation must reuse the interned synthetic KDC; logs:\n{logs}" + ); + + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _third = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 3).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 3, + "newer provisioning generation should replace and still inject; logs:\n{logs}" + ); + let logs = gateway.logs.wait_count(PUBLISHED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 2, + "re-provision must publish exactly one extra synthetic KDC; logs:\n{logs}" + ); + assert_eq!( + logs.matches(REGISTERED_KDC_LOG).count(), + 3, + "each connection should register a synthetic KDC lease; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "Kerberos injection must not ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(3).await?; + assert_eq!( + cookies, + vec![ + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned() + ], + "each decoded X.224 cookie must be the Kerberos target user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "username without a realm must stay NTLM even if krb_kdc is provisioned; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_injection_does_not_need_debug_flags() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=true"), + "Kerberos injection must run without debug flags; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs new file mode 100644 index 000000000..624554b1a --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -0,0 +1,518 @@ +//! Kerberos credential injection against a mock KDC and IronRDP CredSSP server. +//! +//! Proves the target-leg path: Gateway fetches tickets from a TCP KDC (`kdc` crate from +//! sspi-rs) and completes CredSSP with a fake RDP acceptor. The Gateway-facing client uses +//! NTLM so the test does not depend on the in-process synthetic KDC. +//! +//! RDCleanPath coverage drives the public `ironrdp-agent` 0.1.0 CLI (`cargo install +//! ironrdp-agent --version 0.1.0`) over `ws://127.0.0.1/jet/rdp`. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use testsuite::ironrdp_agent::{ + agent_query_logs, connect_rdcleanpath, ironrdp_agent_endpoint, require_ironrdp_agent, start_ironrdp_daemon, +}; +use testsuite::rdp_injection::credssp::{ + complete_client_credssp, complete_ntlm_credssp, complete_raw_ntlm_credssp, connect_ntlm_client, +}; +use testsuite::rdp_injection::gateway::GatewayProc; +use testsuite::rdp_injection::mock_kdc::{ + MockKdc, ObservedKdcReply, ObservedKdcReq, RefusingKdc, assert_target_kdc_as_and_tgs, +}; +use testsuite::rdp_injection::mock_rdp::MockRdp; +use testsuite::rdp_injection::preflight::{provision_credentials, provision_mapping}; +use testsuite::rdp_injection::rdp::{FakeClosedTarget, encode_hybrid_cr, encode_pcb}; +use testsuite::rdp_injection::tls::install_crypto_provider; +use testsuite::rdp_injection::tokens::{association_token_for_host, kdc_proxy_url, next_id}; +use testsuite::rdp_injection::{ + FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_PASSWORD, PROXY_USER, + RDCLEANPATH_FORWARD_LOG, RDCLEANPATH_INJECT_LOG, REALM, SERVICE_HOST, TARGET_PASSWORD, TARGET_USER, +}; +use tokio::io::AsyncWriteExt as _; +use tokio::net::TcpStream; + +#[tokio::test] +async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; + complete_ntlm_credssp(tls) + .await + .context("Gateway-facing NTLM CredSSP")?; + rdp.wait_credssp() + .await + .with_context(|| format!("gateway logs:\n{}", gateway.logs.snapshot()))?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "expected AS-REQ and TGS-REQ against the mock KDC; exchanges={}; gateway logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + assert_target_kdc_as_and_tgs(&kdc)?; + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}; cookies={:?}", + rdp.finished_account(), + rdp.cookies() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &association_token, + PROXY_KERBEROS_USER, + KERBEROS_TARGET_USER, + TARGET_PASSWORD, + 300, + Some(&kdc.url()), + ) + .await?; + + let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &association_jti)?; + let proxy_replies = Mutex::new(Vec::new()); + let proxy_requests = Mutex::new(Vec::new()); + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; + complete_client_credssp( + tls, + PROXY_KERBEROS_USER, + Some(&kdc_proxy), + false, + Some(&proxy_replies), + Some(&proxy_requests), + ) + .await + .with_context(|| { + format!( + "client-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + rdp.wait_credssp().await.with_context(|| { + format!( + "target-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "target-leg must talk to the mock KDC; exchanges={}; logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + assert_target_kdc_as_and_tgs(&kdc)?; + let replies = proxy_replies.lock().expect("proxy reply mutex").clone(); + anyhow::ensure!( + replies.contains(&ObservedKdcReply::AsRep) && replies.contains(&ObservedKdcReply::TgsRep), + "/jet/KdcProxy must return AS-REP and TGS-REP (PREAUTH KRB-ERROR is allowed first); replies={replies:?}" + ); + let requests = proxy_requests.lock().expect("proxy request mutex").clone(); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("injected-proxy-user") && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC AS-REQ must be proxy user injected-proxy-user@{REALM}; requests={requests:?}" + ); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC TGS-REQ must be TERMSRV/{SERVICE_HOST}; requests={requests:?}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &association_token, + PROXY_USER, + KERBEROS_TARGET_USER, + "wrong-target-password", + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; + let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "wrong password must still start Kerberos injection; logs:\n{logs}" + ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if kdc.requests().iter().any(|req| { + matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + ) + }) { + break; + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for AS-REQ as administrator; requests={:?}", + kdc.requests() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + tokio::time::sleep(Duration::from_secs(2)).await; + anyhow::ensure!( + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "wrong target password must not complete Kerberos CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), + gateway.logs.snapshot() + ); + anyhow::ensure!( + !kdc.requests() + .iter() + .any(|req| matches!(req, ObservedKdcReq::Tgs { .. })), + "wrong password must not obtain a TGS; requests={:?}", + kdc.requests() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "wrong password still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "wrong password must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = RefusingKdc::start().await?; + let rdp_kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(rdp_kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; + let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "KDC down must still start Kerberos injection; logs:\n{logs}" + ); + let deadline = Instant::now() + Duration::from_secs(10); + while kdc.accepted() == 0 { + if Instant::now() >= deadline { + anyhow::bail!("Gateway never TCP-connected the provisioned KDC"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + anyhow::ensure!( + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "unreachable KDC must not complete CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), + gateway.logs.snapshot() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "KDC down still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); + anyhow::ensure!( + kdc.accepted() >= 1, + "Gateway must TCP-connect the provisioned KDC; accepted={}", + kdc.accepted() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "KDC down must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { + let rdp = FakeClosedTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + None, + ) + .await?; + + let mut stream = TcpStream::connect(("127.0.0.1", gateway.config.tcp_port())) + .await + .context("connect gateway TCP")?; + stream + .write_all(&encode_pcb(&association_token)?) + .await + .context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write CR")?; + stream.flush().await.context("flush CR")?; + let logs = gateway.logs.wait_contains(MISSING_LOG).await?; + anyhow::ensure!( + !logs.contains(FORWARD_LOG) && !logs.contains(INJECT_LOG), + "missing krb_kdc must fail closed; logs:\n{logs}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + anyhow::ensure!( + rdp.accepted() == 0, + "missing krb_kdc must not dial the target; accepted={}", + rdp.accepted() + ); + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start().await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; + complete_raw_ntlm_credssp(tls) + .await + .with_context(|| format!("client-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + rdp.wait_credssp() + .await + .with_context(|| format!("target-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == TARGET_USER), + "RDP X.224 cookie must be {TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start().await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; + + let mut connect = connect_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + PROXY_USER, + PROXY_PASSWORD, + &association_token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath NTLM target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let mut connect = connect_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + PROXY_USER, + PROXY_PASSWORD, + &association_token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath Kerberos target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + assert_target_kdc_as_and_tgs(&kdc)?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index c6cc1226b..acb5a50a6 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,5 +1,7 @@ mod benign_disconnect; mod cli_args; +mod cred_injection; +mod cred_injection_kdc; mod heartbeat; mod preflight; mod tls_anchoring; diff --git a/testsuite/tests/cli/dgw/tls_anchoring.rs b/testsuite/tests/cli/dgw/tls_anchoring.rs index b45669ad2..36fbafac5 100644 --- a/testsuite/tests/cli/dgw/tls_anchoring.rs +++ b/testsuite/tests/cli/dgw/tls_anchoring.rs @@ -168,56 +168,7 @@ async fn start_dummy_tls_server() -> anyhow::Result { } mod tls { - /// Self-signed certificate for localhost (valid for 100 years). - pub(super) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- -MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx -MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj -cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 -RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc -34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 -pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh -ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud -DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF -3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB -i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH -05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R -IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ -RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 -x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 -/v4eYM0cCHN0h+rynSor ------END CERTIFICATE-----"#; - - /// Private key for the self-signed certificate. - pub(super) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c -QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf -m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW -a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv -Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg -UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j -rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ -2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E -Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N -fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD -Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B -MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L -c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW -jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH -c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G -ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo -Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt -N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ -STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO -udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC -AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q -aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En -+XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL -WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS -KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR -bQGM8UrNQvWQ+tutRmp7PM6VuQ== ------END PRIVATE KEY-----"#; + pub(super) use testsuite::tls_fixtures::{CERT_PEM, KEY_PEM}; /// SHA-256 thumbprint of the certificate. pub(super) const CERT_THUMBPRINT: &str = "bce13f257b9d856404c51b46f2420eff6d01b3a4c99fe3d0e11e4517c2291b70";