From 9de0ba32eb829e6cb475014e4d7fe1c02aadcf46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:03:32 +0000 Subject: [PATCH 01/11] fix(agent): avoid leaking tunnel enrollment JWT via process cmdline and MSI logs - Rust: accept `--enrollment-string -` sentinel to read JWT from stdin - Rust: factor parsing into `parse_up_command_args_with_reader` for testability - Rust: add unit tests for stdin path and empty stdin error - C#: pass JWT via stdin instead of argv in EnrollAgentTunnel - C#: add EscapeArg helper for safe Windows argv quoting of --name - WiX: add AGENT_TUNNEL_ENROLLMENT_STRING to MsiHiddenProperties Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/80ac4af5-a42b-40aa-8cca-b3f8837f0a4b Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- devolutions-agent/src/main.rs | 51 ++++++++++++++++ .../Actions/CustomActions.cs | 58 ++++++++++++++++++- package/AgentWindowsManaged/Program.cs | 5 ++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 71ca21580..aea199121 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -36,6 +36,7 @@ extern crate tracing; mod service; use std::env; +use std::io::{self, BufRead}; use std::sync::mpsc; use anyhow::{Context as _, Result, bail}; @@ -143,6 +144,10 @@ fn parse_advertise_subnets(value: &str) -> Vec { } fn parse_up_command_args(args: &[String]) -> Result { + parse_up_command_args_with_reader(args, io::stdin().lock()) +} + +fn parse_up_command_args_with_reader(args: &[String], stdin_reader: R) -> Result { let mut gateway_url = None; let mut enrollment_token = None; let mut agent_name = None; @@ -168,6 +173,22 @@ fn parse_up_command_args(args: &[String]) -> Result { } if let Some(enrollment_string) = enrollment_string { + // A single hyphen means "read the enrollment string from stdin". + let enrollment_string = if enrollment_string == "-" { + let mut buf = String::new(); + for line in stdin_reader.lines() { + let line = line.context("failed to read enrollment string from stdin")?; + buf.push_str(&line); + } + let trimmed = buf.trim().to_owned(); + if trimmed.is_empty() { + bail!("enrollment string read from stdin is empty"); + } + trimmed + } else { + enrollment_string + }; + let claims = parse_enrollment_jwt(&enrollment_string)?; // The JWT itself is the Bearer token; the Gateway verifies the signature. @@ -374,4 +395,34 @@ mod tests { assert_eq!(parsed.enrollment_token, jwt); assert_eq!(parsed.agent_name, "site-a-agent"); } + + #[test] + fn parse_up_command_args_reads_enrollment_string_from_stdin() { + let jwt = make_jwt(serde_json::json!({ + "scope": "gateway.tunnel.enroll", + "exp": 1_999_999_999i64, + "jti": "00000000-0000-0000-0000-000000000000", + "jet_gw_url": "https://gateway.example.com:7171", + "jet_agent_name": "site-a-agent", + })); + let args = vec!["--enrollment-string".to_owned(), "-".to_owned()]; + + // Simulate stdin by providing the JWT through a reader. + let fake_stdin = io::Cursor::new(jwt.clone()); + let parsed = parse_up_command_args_with_reader(&args, fake_stdin).expect("parse up args from stdin"); + + assert_eq!(parsed.gateway_url, "https://gateway.example.com:7171"); + assert_eq!(parsed.enrollment_token, jwt); + assert_eq!(parsed.agent_name, "site-a-agent"); + } + + #[test] + fn parse_up_command_args_stdin_empty_is_error() { + let args = vec!["--enrollment-string".to_owned(), "-".to_owned()]; + let fake_stdin = io::Cursor::new(""); + let result = parse_up_command_args_with_reader(&args, fake_stdin); + assert!(result.is_err()); + let err = result.expect_err("expected error for empty stdin"); + assert!(err.to_string().contains("empty"), "error should mention empty stdin"); + } } diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index a3d7f19f2..ebd15bdd0 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -366,10 +366,13 @@ ActionResult Fail(string msg) // else (advertise subnets, advertise domains) is patched into agent.json *after* // enrollment, so we don't accumulate parallel CLI surfaces for what is ultimately // configuration data. - string arguments = $"up --enrollment-string \"{enrollmentString}\""; + // + // The JWT is passed via stdin (sentinel `-`) to avoid exposing it in the process + // command line (visible to any local process via WMI / Process Explorer / ETW). + string arguments = "up --enrollment-string -"; if (resolvedName.Length != 0) { - arguments += $" --name \"{resolvedName}\""; + arguments += $" --name {EscapeArg(resolvedName)}"; } string Redact(string s) => s.Replace(enrollmentString, "***"); @@ -380,11 +383,17 @@ ActionResult Fail(string msg) UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + RedirectStandardInput = true, CreateNoWindow = true, WorkingDirectory = ProgramDataDirectory, }; using Process process = Process.Start(startInfo); + + // Write the JWT to stdin and close it so the child sees EOF. + process.StandardInput.Write(enrollmentString); + process.StandardInput.Close(); + if (!process.WaitForExit(60_000)) { try @@ -447,6 +456,51 @@ private static bool JwtHasAgentName(string jwt) } } + /// + /// Escape a single argument for the Windows command line using the + /// CommandLineToArgvW rules: internal double-quotes are escaped + /// as \", backslash runs immediately before a quote are doubled, + /// and the whole value is wrapped in double quotes. + /// + private static string EscapeArg(string arg) + { + StringBuilder sb = new(); + sb.Append('"'); + + for (int i = 0; i < arg.Length; i++) + { + int backslashes = 0; + while (i < arg.Length && arg[i] == '\\') + { + backslashes++; + i++; + } + + if (i == arg.Length) + { + // Trailing backslashes must be doubled because the + // closing quote follows immediately. + sb.Append('\\', backslashes * 2); + break; + } + else if (arg[i] == '"') + { + // Backslashes before a double-quote must be doubled, + // plus one extra to escape the quote itself. + sb.Append('\\', backslashes * 2 + 1); + sb.Append('"'); + } + else + { + sb.Append('\\', backslashes); + sb.Append(arg[i]); + } + } + + sb.Append('"'); + return sb.ToString(); + } + /// /// Patch the freshly-written agent.json's Tunnel section with the operator's /// advertised subnets and DNS suffixes from the wizard. Keeping this out of the diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index 2d9ead71c..daa16fef1 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -349,6 +349,11 @@ static void Main() // - Make DevolutionsDesktopAgent answer WM_CLOSE projectProperties.Add(new Property("MSIRESTARTMANAGERCONTROL", "Disable")); + // Prevent the enrollment JWT from being logged in verbose MSI logs (/L*v). + // `Hidden = true` only suppresses the property table dump; MsiHiddenProperties + // controls masking of CustomActionData expansion in verbose logs. + projectProperties.Add(new Property("MsiHiddenProperties", AgentProperties.AgentTunnelEnrollmentString)); + // Agent tunnel properties: must be declared Secure so the values set in the wizard UI // survive the UAC boundary and reach the deferred CA via CustomActionData. projectProperties.Add(new Property(AgentProperties.AgentTunnelEnrollmentString, "") { Hidden = true, Secure = true }); From 0f73bc24b27dce6736960cf64c6aa1c9c1e09e67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 07:35:07 +0000 Subject: [PATCH 02/11] test(agent): move stdin enrollment tests to testsuite E2E cli module Remove unit tests for stdin path from devolutions-agent/src/main.rs and add proper E2E tests in testsuite/tests/cli/agent/up.rs that exercise the actual binary. Add agent_assert_cmd() to the testsuite cli module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/89161343-5614-437e-8fb3-d06809bd7323 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- devolutions-agent/src/main.rs | 32 +-------- testsuite/src/cli.rs | 18 +++++ testsuite/tests/cli/agent/mod.rs | 1 + testsuite/tests/cli/agent/up.rs | 114 +++++++++++++++++++++++++++++++ testsuite/tests/cli/mod.rs | 1 + 5 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 testsuite/tests/cli/agent/mod.rs create mode 100644 testsuite/tests/cli/agent/up.rs diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index aea199121..7f1beed2a 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -391,38 +391,8 @@ mod tests { let parsed = parse_up_command_args(&args).expect("parse up args"); assert_eq!(parsed.gateway_url, "https://gateway.example.com:7171"); - // The JWT itself is used as the Bearer token for /jet/tunnel/enroll. + // The JWT itself is used as the ****** for /jet/tunnel/enroll. assert_eq!(parsed.enrollment_token, jwt); assert_eq!(parsed.agent_name, "site-a-agent"); } - - #[test] - fn parse_up_command_args_reads_enrollment_string_from_stdin() { - let jwt = make_jwt(serde_json::json!({ - "scope": "gateway.tunnel.enroll", - "exp": 1_999_999_999i64, - "jti": "00000000-0000-0000-0000-000000000000", - "jet_gw_url": "https://gateway.example.com:7171", - "jet_agent_name": "site-a-agent", - })); - let args = vec!["--enrollment-string".to_owned(), "-".to_owned()]; - - // Simulate stdin by providing the JWT through a reader. - let fake_stdin = io::Cursor::new(jwt.clone()); - let parsed = parse_up_command_args_with_reader(&args, fake_stdin).expect("parse up args from stdin"); - - assert_eq!(parsed.gateway_url, "https://gateway.example.com:7171"); - assert_eq!(parsed.enrollment_token, jwt); - assert_eq!(parsed.agent_name, "site-a-agent"); - } - - #[test] - fn parse_up_command_args_stdin_empty_is_error() { - let args = vec!["--enrollment-string".to_owned(), "-".to_owned()]; - let fake_stdin = io::Cursor::new(""); - let result = parse_up_command_args_with_reader(&args, fake_stdin); - assert!(result.is_err()); - let err = result.expect_err("expected error for empty stdin"); - assert!(err.to_string().contains("empty"), "error should mention empty stdin"); - } } diff --git a/testsuite/src/cli.rs b/testsuite/src/cli.rs index a18c5fa34..cfd818347 100644 --- a/testsuite/src/cli.rs +++ b/testsuite/src/cli.rs @@ -65,6 +65,24 @@ pub fn dgw_tokio_cmd() -> tokio::process::Command { cmd } +static AGENT_BIN_PATH: LazyLock = LazyLock::new(|| { + escargot::CargoBuild::new() + .manifest_path("../devolutions-agent/Cargo.toml") + .bin("devolutions-agent") + .current_release() + .current_target() + .run() + .expect("build Devolutions Agent") + .path() + .to_path_buf() +}); + +pub fn agent_assert_cmd() -> assert_cmd::Command { + let mut cmd = assert_cmd::Command::new(&*AGENT_BIN_PATH); + cmd.env("RUST_BACKTRACE", "0"); + cmd +} + pub fn assert_stderr_eq(output: &assert_cmd::assert::Assert, expected: expect_test::Expect) { let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); expected.assert_eq(stderr); diff --git a/testsuite/tests/cli/agent/mod.rs b/testsuite/tests/cli/agent/mod.rs new file mode 100644 index 000000000..786e3668c --- /dev/null +++ b/testsuite/tests/cli/agent/mod.rs @@ -0,0 +1 @@ +mod up; diff --git a/testsuite/tests/cli/agent/up.rs b/testsuite/tests/cli/agent/up.rs new file mode 100644 index 000000000..32bf352d3 --- /dev/null +++ b/testsuite/tests/cli/agent/up.rs @@ -0,0 +1,114 @@ +//! E2E tests for `devolutions-agent up` argument parsing, +//! focusing on the `--enrollment-string -` stdin path. + +use base64::Engine as _; +use testsuite::cli::agent_assert_cmd; + +/// Build a JWT with the given payload. The header and signature are placeholders — +/// the agent does not verify them; only the Gateway does. +fn make_jwt(payload: serde_json::Value) -> String { + let header = serde_json::json!({ "alg": "RS256", "typ": "JWT" }); + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + format!( + "{}.{}.{}", + b64.encode(header.to_string()), + b64.encode(payload.to_string()), + b64.encode("signature-placeholder"), + ) +} + +fn sample_jwt() -> String { + make_jwt(serde_json::json!({ + "scope": "gateway.tunnel.enroll", + "exp": 1_999_999_999i64, + "jti": "00000000-0000-0000-0000-000000000000", + "jet_gw_url": "https://gateway.example.com:7171", + "jet_agent_name": "site-a-agent", + })) +} + +/// `up --enrollment-string ` (inline) should parse the JWT and proceed to +/// enrollment. Since there is no real Gateway, enrollment fails with a network +/// error — but the argument parsing stage must succeed (no "Invalid up arguments" error). +#[test] +fn up_enrollment_string_inline() { + let jwt = sample_jwt(); + + let output = agent_assert_cmd() + .args(["up", "--enrollment-string", &jwt]) + .assert() + .failure(); // Enrollment itself fails — no gateway. + + let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); + assert!( + !stderr.contains("Invalid up arguments"), + "argument parsing should succeed; stderr was: {stderr}" + ); + assert!( + stderr.contains("Bootstrap failed"), + "should fail at enrollment, not parsing; stderr was: {stderr}" + ); +} + +/// `up --enrollment-string -` reads the JWT from stdin. The enrollment fails (no +/// real gateway), but the fact that it gets past argument parsing proves stdin +/// reading works end-to-end. +#[test] +fn up_enrollment_string_from_stdin() { + let jwt = sample_jwt(); + + let output = agent_assert_cmd() + .args(["up", "--enrollment-string", "-"]) + .write_stdin(jwt) + .assert() + .failure(); + + let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); + assert!( + !stderr.contains("Invalid up arguments"), + "argument parsing should succeed; stderr was: {stderr}" + ); + assert!( + stderr.contains("Bootstrap failed"), + "should fail at enrollment, not parsing; stderr was: {stderr}" + ); +} + +/// `up --enrollment-string -` with empty stdin must report an error about an +/// empty enrollment string. +#[test] +fn up_enrollment_string_stdin_empty_is_error() { + let output = agent_assert_cmd() + .args(["up", "--enrollment-string", "-"]) + .write_stdin("") + .assert() + .failure(); + + let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); + assert!( + stderr.contains("empty"), + "error should mention empty enrollment string; stderr was: {stderr}" + ); +} + +/// The JWT must not appear anywhere in the process command line when the +/// stdin sentinel `-` is used. We verify this indirectly: the only +/// arguments on the command line are `up --enrollment-string -`, so +/// `assert_cmd`'s captured stderr (which includes the error) should not +/// contain the JWT itself. +#[test] +fn up_enrollment_string_stdin_does_not_leak_jwt_in_stderr() { + let jwt = sample_jwt(); + + let output = agent_assert_cmd() + .args(["up", "--enrollment-string", "-"]) + .write_stdin(jwt.clone()) + .assert() + .failure(); + + let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); + assert!( + !stderr.contains(&jwt), + "JWT should not appear in stderr; stderr was: {stderr}" + ); +} diff --git a/testsuite/tests/cli/mod.rs b/testsuite/tests/cli/mod.rs index c895a483c..43da9ed3a 100644 --- a/testsuite/tests/cli/mod.rs +++ b/testsuite/tests/cli/mod.rs @@ -1,2 +1,3 @@ +mod agent; mod dgw; mod jetsocat; From 07a54dd9a5c09d04871728b5581af01bae633d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 26 May 2026 17:19:38 +0900 Subject: [PATCH 03/11] Apply suggestion from @CBenoit --- devolutions-agent/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index 7f1beed2a..e7c759c7a 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -391,7 +391,7 @@ mod tests { let parsed = parse_up_command_args(&args).expect("parse up args"); assert_eq!(parsed.gateway_url, "https://gateway.example.com:7171"); - // The JWT itself is used as the ****** for /jet/tunnel/enroll. + // The JWT itself is used as the Bearer token for /jet/tunnel/enroll. assert_eq!(parsed.enrollment_token, jwt); assert_eq!(parsed.agent_name, "site-a-agent"); } From 58b90a9513ad10b08369c1131995c5c77b31a62c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 08:47:59 +0000 Subject: [PATCH 04/11] refactor(agent): read only first line from stdin, add E2E enrollment test with real Gateway - Read only the first line from stdin for --enrollment-string - - Remove low-value JWT-leak-in-stderr test - Add E2E test that spins up a real Gateway with agent tunnel enabled, signs a proper JWT with a generated provisioner key, and enrolls the agent via stdin - Extend DgwConfig with provisioner_public_key_base64 and agent_tunnel fields for test configuration Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/4dace32b-140a-4a30-a73b-671a0bc27cf4 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- Cargo.lock | 3 + devolutions-agent/src/main.rs | 13 ++-- testsuite/Cargo.toml | 3 + testsuite/src/dgw_config.rs | 44 ++++++++++++- testsuite/tests/cli/agent/up.rs | 108 +++++++++++++++++++++----------- 5 files changed, 126 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9032ce18d..a6cec710c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7093,6 +7093,7 @@ dependencies = [ "mcp-proxy", "network-scanner", "network-scanner-proto", + "picky", "proxy-socks", "rstest", "serde", @@ -7102,11 +7103,13 @@ dependencies = [ "sysevent-winevent", "tempfile", "test-utils", + "time", "tokio 1.52.3", "tokio-rustls", "tokio-tungstenite 0.26.2", "tokio-util", "typed-builder", + "uuid", ] [[package]] diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index e7c759c7a..b2ed0acbd 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -147,7 +147,7 @@ fn parse_up_command_args(args: &[String]) -> Result { parse_up_command_args_with_reader(args, io::stdin().lock()) } -fn parse_up_command_args_with_reader(args: &[String], stdin_reader: R) -> Result { +fn parse_up_command_args_with_reader(args: &[String], mut stdin_reader: R) -> Result { let mut gateway_url = None; let mut enrollment_token = None; let mut agent_name = None; @@ -175,12 +175,11 @@ fn parse_up_command_args_with_reader(args: &[String], stdin_reader: if let Some(enrollment_string) = enrollment_string { // A single hyphen means "read the enrollment string from stdin". let enrollment_string = if enrollment_string == "-" { - let mut buf = String::new(); - for line in stdin_reader.lines() { - let line = line.context("failed to read enrollment string from stdin")?; - buf.push_str(&line); - } - let trimmed = buf.trim().to_owned(); + let mut line = String::new(); + stdin_reader + .read_line(&mut line) + .context("failed to read enrollment string from stdin")?; + let trimmed = line.trim().to_owned(); if trimmed.is_empty() { bail!("enrollment string read from stdin is empty"); } diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 6473a602b..34f23fbab 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -38,11 +38,14 @@ mcp-proxy.path = "../crates/mcp-proxy" network-scanner = { path = "../crates/network-scanner", features = ["test-utils"] } network-scanner-proto = { path = "../crates/network-scanner-proto" } rstest = "0.25" +picky = { version = "7.0.0-rc.15", default-features = false, features = ["jose"] } serde_json = "1" sysevent.path = "../crates/sysevent" tempfile = "3" test-utils.path = "../crates/test-utils" +time = "0.3" tokio-rustls = { version = "0.26", features = ["ring"] } +uuid = { version = "1", features = ["v4", "serde"] } [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/src/dgw_config.rs b/testsuite/src/dgw_config.rs index 74cf16b04..47729093b 100644 --- a/testsuite/src/dgw_config.rs +++ b/testsuite/src/dgw_config.rs @@ -38,6 +38,17 @@ pub struct AiGatewayConfig { pub openai_api_key: Option, } +/// Configuration for the agent tunnel feature in tests. +#[derive(Clone, TypedBuilder)] +pub struct AgentTunnelConfig { + /// Whether the agent tunnel is enabled. + #[builder(default = true)] + pub enabled: bool, + /// UDP port for the QUIC listener. + #[builder(default, setter(into))] + pub listen_port: Option, +} + #[derive(TypedBuilder)] pub struct DgwConfig { #[builder(default, setter(into))] @@ -60,6 +71,15 @@ pub struct DgwConfig { /// Pass a path that does not yet exist to test behaviour before the folder is created. #[builder(default, setter(into))] recording_path: Option, + /// Base64-encoded DER provisioner public key. + /// + /// When `Some`, replaces the default test provisioner key. Used by tests that + /// need to sign JWTs with a matching private key (e.g. enrollment tests). + #[builder(default, setter(into))] + provisioner_public_key_base64: Option, + /// Agent tunnel (QUIC) configuration. + #[builder(default, setter(into))] + agent_tunnel: Option, } fn find_unused_port() -> u16 { @@ -92,6 +112,8 @@ impl DgwConfigHandle { ai_gateway, enable_unstable, recording_path, + provisioner_public_key_base64, + agent_tunnel, } = config; let tempdir = tempfile::tempdir().context("create tempdir")?; @@ -155,10 +177,28 @@ impl DgwConfigHandle { String::new() }; + let provisioner_key_value = provisioner_public_key_base64.unwrap_or_else(|| { + "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB".to_owned() + }); + + let agent_tunnel_json = if let Some(at_config) = agent_tunnel { + let listen_port = at_config.listen_port.unwrap_or_else(find_unused_port); + format!( + r#", + "AgentTunnel": {{ + "Enabled": {}, + "ListenPort": {listen_port} + }}"#, + at_config.enabled + ) + } else { + String::new() + }; + let config = format!( r#"{{ "ProvisionerPublicKeyData": {{ - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" + "Value": "{provisioner_key_value}" }}, "Listeners": [ {{ @@ -174,7 +214,7 @@ impl DgwConfigHandle { "__debug__": {{ "disable_token_validation": {disable_token_validation}, "enable_unstable": {enable_unstable} - }}{ai_gateway_json}{recording_path_json} + }}{ai_gateway_json}{recording_path_json}{agent_tunnel_json} }}"# ); diff --git a/testsuite/tests/cli/agent/up.rs b/testsuite/tests/cli/agent/up.rs index 32bf352d3..8099e4731 100644 --- a/testsuite/tests/cli/agent/up.rs +++ b/testsuite/tests/cli/agent/up.rs @@ -1,4 +1,4 @@ -//! E2E tests for `devolutions-agent up` argument parsing, +//! E2E tests for `devolutions-agent up` enrollment, //! focusing on the `--enrollment-string -` stdin path. use base64::Engine as _; @@ -27,29 +27,6 @@ fn sample_jwt() -> String { })) } -/// `up --enrollment-string ` (inline) should parse the JWT and proceed to -/// enrollment. Since there is no real Gateway, enrollment fails with a network -/// error — but the argument parsing stage must succeed (no "Invalid up arguments" error). -#[test] -fn up_enrollment_string_inline() { - let jwt = sample_jwt(); - - let output = agent_assert_cmd() - .args(["up", "--enrollment-string", &jwt]) - .assert() - .failure(); // Enrollment itself fails — no gateway. - - let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); - assert!( - !stderr.contains("Invalid up arguments"), - "argument parsing should succeed; stderr was: {stderr}" - ); - assert!( - stderr.contains("Bootstrap failed"), - "should fail at enrollment, not parsing; stderr was: {stderr}" - ); -} - /// `up --enrollment-string -` reads the JWT from stdin. The enrollment fails (no /// real gateway), but the fact that it gets past argument parsing proves stdin /// reading works end-to-end. @@ -91,24 +68,83 @@ fn up_enrollment_string_stdin_empty_is_error() { ); } -/// The JWT must not appear anywhere in the process command line when the -/// stdin sentinel `-` is used. We verify this indirectly: the only -/// arguments on the command line are `up --enrollment-string -`, so -/// `assert_cmd`'s captured stderr (which includes the error) should not -/// contain the JWT itself. -#[test] -fn up_enrollment_string_stdin_does_not_leak_jwt_in_stderr() { - let jwt = sample_jwt(); +/// Enrollment against a real Gateway with a properly signed JWT. +/// +/// Starts a Gateway with agent tunnel enabled, signs a JWT with the +/// matching provisioner key, and runs `devolutions-agent up` via stdin. +/// The enrollment should succeed (HTTP 200 from the Gateway). +#[tokio::test] +async fn up_enrollment_against_real_gateway() { + use anyhow::Context as _; + use picky::jose::jws::JwsAlg; + use picky::jose::jwt::CheckedJwtSig; + use picky::key::PrivateKey; + use testsuite::cli::{agent_assert_cmd, dgw_tokio_cmd, wait_for_tcp_port}; + use testsuite::dgw_config::{AgentTunnelConfig, DgwConfig}; + + // Generate a fresh provisioner key pair for this test. + let priv_key = PrivateKey::generate_rsa(2048).expect("generate RSA key"); + let pub_key = priv_key.to_public_key().expect("derive public key"); + + // Multibase-encode the SPKI DER for the gateway config (prefix 'm' = base64). + let pub_key_der = pub_key.to_der().expect("export SPKI DER"); + let pub_key_multibase = format!("m{}", base64::engine::general_purpose::STANDARD.encode(&pub_key_der)); + + let config_handle = DgwConfig::builder() + .provisioner_public_key_base64(pub_key_multibase) + .agent_tunnel(AgentTunnelConfig::builder().build()) + .enable_unstable(true) + .build() + .init() + .expect("init gateway config"); + + // Start a real Gateway. + let mut gateway = dgw_tokio_cmd() + .env("DGATEWAY_CONFIG_PATH", config_handle.config_dir()) + .kill_on_drop(true) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("start gateway") + .expect("spawn gateway"); + + wait_for_tcp_port(config_handle.http_port()) + .await + .expect("gateway HTTP port ready"); + + // Sign a proper enrollment JWT with the provisioner private key. + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let jwt = CheckedJwtSig::new( + JwsAlg::RS256, + serde_json::json!({ + "scope": "gateway.agent.enroll", + "nbf": now - 60, + "exp": now + 3600, + "jti": uuid::Uuid::new_v4(), + "jet_gw_url": format!("http://127.0.0.1:{}", config_handle.http_port()), + "jet_agent_name": "test-agent", + }), + ) + .encode(&priv_key) + .expect("sign enrollment JWT"); + + // Run the agent with --enrollment-string - (stdin). + // Set DAGENT_CONFIG_PATH so certs are written to a temp directory. + let agent_data_dir = tempfile::tempdir().expect("create agent temp dir"); let output = agent_assert_cmd() + .env("DAGENT_CONFIG_PATH", agent_data_dir.path()) .args(["up", "--enrollment-string", "-"]) - .write_stdin(jwt.clone()) + .write_stdin(jwt) + .timeout(std::time::Duration::from_secs(30)) .assert() - .failure(); + .success(); let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap(); assert!( - !stderr.contains(&jwt), - "JWT should not appear in stderr; stderr was: {stderr}" + !stderr.contains("Bootstrap failed"), + "enrollment should succeed; stderr was: {stderr}" ); + + gateway.kill().await.ok(); } From 18280c2a1cc0941759fd2858d808a22f0d2025d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:25:37 +0000 Subject: [PATCH 05/11] test(agent): hardcode provisioner test key instead of generating at runtime Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/3fd36380-b176-4ab2-b0cb-a90ca4028743 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- testsuite/tests/cli/agent/up.rs | 44 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/testsuite/tests/cli/agent/up.rs b/testsuite/tests/cli/agent/up.rs index 8099e4731..ab8ce5b66 100644 --- a/testsuite/tests/cli/agent/up.rs +++ b/testsuite/tests/cli/agent/up.rs @@ -82,16 +82,44 @@ async fn up_enrollment_against_real_gateway() { use testsuite::cli::{agent_assert_cmd, dgw_tokio_cmd, wait_for_tcp_port}; use testsuite::dgw_config::{AgentTunnelConfig, DgwConfig}; - // Generate a fresh provisioner key pair for this test. - let priv_key = PrivateKey::generate_rsa(2048).expect("generate RSA key"); - let pub_key = priv_key.to_public_key().expect("derive public key"); - - // Multibase-encode the SPKI DER for the gateway config (prefix 'm' = base64). - let pub_key_der = pub_key.to_der().expect("export SPKI DER"); - let pub_key_multibase = format!("m{}", base64::engine::general_purpose::STANDARD.encode(&pub_key_der)); + // Hardcoded test provisioner key (same as in devolutions-gateway/tests/token_security.rs). + // Security is not required for these tests; we just need a valid key pair. + const PROVISIONER_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDkrPiL/5dmGIT5 +/KuC3H/jIjeLoLoddsLhAlikO5JQQo3Zs71GwT4Wd2z8WLMe0lVZu/Jr2S28p0M8 +F3Lnz4IgzjocQomFgucFWWQRyD03ZE2BHfEeelFsp+/4GZaM6lKZauYlIMtjR1vD +lflgvxNTr0iaii4JR9K3IKCunCRy1HQYPcZ9waNtlG5xXtW9Uf1tLWPJpP/3I5HL +M85JPBv4r286vpeUlfQIa/NB4g5w6KZ6MfEAIU4KeEQpeLAyyYvwUzPR2uQZ4y4I +4Nj84dWYB1cMTlSGugvSgOFKYit1nwLGeA7EevVYPbILRfSMBU/+avGNJJ8HCaaq +FIyY42W9AgMBAAECggEBAImsGXcvydaNrIFUvW1rkxML5qUJfwN+HJWa9ALsWoo3 +h28p5ypR7S9ZdyP1wuErgHcl0C1d80tA6BmlhGhLZeyaPCIHbQQUa0GtL7IE+9X9 +bSvu+tt+iMcB1FdqEFmGOXRkB2sS82Ax9e0qvZihcOFRBkUEK/MqapIV8qctGkSG +wIE6yn5LHRls/fJU8BJeeqJmYpuWljipwTkp9hQ7SdRYFLNjwjlz/b0hjmgFs5QZ +LUNMyTHdHtXQHNsf/GayRUAKf5wzN/jru+nK6lMob2Ehfx9/RAfgaDHzy5BNFMj0 +i9+sAycgIW1HpTuDvSEs3qP26NeQ82GbJzATmdAKa4ECgYEA9Vti0YG+eXJI3vdS +uXInU0i1SY4aEG397OlGMwh0yQnp2KGruLZGkTvqxG/Adj1ObDyjFH9XUhMrd0za +Nk/VJFybWafljUPcrfyPAVLQLjsBfMg3Y34sTF6QjUnhg49X2jfvy9QpC5altCtA +46/KVAGREnQJ3wMjfGGIFP8BUZsCgYEA7phYE/cYyWg7a/o8eKOFGqs11ojSqG3y +0OE7kvW2ugUuy3ex+kr19Q/8pOWEc7M1UEV8gmc11xgB70EhIFt9Jq379H0X4ahS ++mgLiPzKAdNCRPpkxwwN9HxFDgGWoYcgMplhoAmg9lWSDuE1Exy8iu5inMWuF4MT +/jG+cLnUZ4cCgYAfMIXIUjDvaUrAJTp73noHSUfaWNkRW5oa4rCMzjdiUwNKCYs1 +yN4BmldGr1oM7dApTDAC7AkiotM0sC1RGCblH2yUIha5NXY5G9Dl/yv9pHyU6zK3 +UBO7hY3kmA611aP6VoACLi8ljPn1hEYUa4VR1n0llmCm29RH/HH7EUuOnwKBgExH +OCFp5eq+AAFNRvfqjysvgU7M/0wJmo9c8obRN1HRRlyWL7gtLuTh74toNSgoKus2 +y8+E35mce0HaOJT3qtMq3FoVhAUIoz6a9NUevBZJS+5xfraEDBIViJ4ps9aANLL4 +hlV7vpICWWeYaDdsAHsKK0yjhjzOEx45GQFA578RAoGBAOB42BG53tL0G9pPeJPt +S2LM6vQKeYx+gXTk6F335UTiiC8t0CgNNQUkW105P/SdpCTTKojAsOPMKOF7z4mL +lj/bWmNq7xu9uVOcBKrboVFGO/n6FXyWZxHPOTdjTkpe8kvvmSwl2iaTNllvSr46 +Z/fDKMxHxeXla54kfV+HiGkH +-----END PRIVATE KEY-----"#; + + // Precomputed SPKI DER of PROVISIONER_KEY_PEM, multibase-encoded (prefix 'm' = base64). + const PROVISIONER_PUB_KEY_MULTIBASE: &str = "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Kz4i/+XZhiE+fyrgtx/4yI3i6C6HXbC4QJYpDuSUEKN2bO9RsE+Fnds/FizHtJVWbvya9ktvKdDPBdy58+CIM46HEKJhYLnBVlkEcg9N2RNgR3xHnpRbKfv+BmWjOpSmWrmJSDLY0dbw5X5YL8TU69ImoouCUfStyCgrpwkctR0GD3GfcGjbZRucV7VvVH9bS1jyaT/9yORyzPOSTwb+K9vOr6XlJX0CGvzQeIOcOimejHxACFOCnhEKXiwMsmL8FMz0drkGeMuCODY/OHVmAdXDE5UhroL0oDhSmIrdZ8CxngOxHr1WD2yC0X0jAVP/mrxjSSfBwmmqhSMmONlvQIDAQAB"; + + let priv_key = PrivateKey::from_pem_str(PROVISIONER_KEY_PEM).expect("parse test provisioner key"); let config_handle = DgwConfig::builder() - .provisioner_public_key_base64(pub_key_multibase) + .provisioner_public_key_base64(PROVISIONER_PUB_KEY_MULTIBASE.to_owned()) .agent_tunnel(AgentTunnelConfig::builder().build()) .enable_unstable(true) .build() From ba3034657f72910f6c47bd51541228428fe5f1af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:30:30 +0000 Subject: [PATCH 06/11] test(agent): hardcode JTI UUID and remove uuid dependency from testsuite Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/f2d44853-04c2-421f-b7a4-9eafeea3f534 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- Cargo.lock | 1 - testsuite/Cargo.toml | 2 +- testsuite/tests/cli/agent/up.rs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6cec710c..4253d5a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7109,7 +7109,6 @@ dependencies = [ "tokio-tungstenite 0.26.2", "tokio-util", "typed-builder", - "uuid", ] [[package]] diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 34f23fbab..7a13caf4d 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -45,7 +45,7 @@ tempfile = "3" test-utils.path = "../crates/test-utils" time = "0.3" tokio-rustls = { version = "0.26", features = ["ring"] } -uuid = { version = "1", features = ["v4", "serde"] } + [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/tests/cli/agent/up.rs b/testsuite/tests/cli/agent/up.rs index ab8ce5b66..e1b207a05 100644 --- a/testsuite/tests/cli/agent/up.rs +++ b/testsuite/tests/cli/agent/up.rs @@ -148,7 +148,7 @@ Z/fDKMxHxeXla54kfV+HiGkH "scope": "gateway.agent.enroll", "nbf": now - 60, "exp": now + 3600, - "jti": uuid::Uuid::new_v4(), + "jti": "b1e2c3d4-5678-9abc-def0-123456789abc", "jet_gw_url": format!("http://127.0.0.1:{}", config_handle.http_port()), "jet_agent_name": "test-agent", }), From b281b223a02b7d750dc0b59db3bb4647ea730615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:41:32 +0000 Subject: [PATCH 07/11] refactor(agent): simplify enrollment test with disable_token_validation, remove picky/time deps Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/dc1b1526-3e01-4852-b247-6e2a86932742 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- Cargo.lock | 2 - testsuite/Cargo.toml | 2 - testsuite/src/dgw_config.rs | 11 +---- testsuite/tests/cli/agent/up.rs | 75 ++++++--------------------------- 4 files changed, 13 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4253d5a68..9032ce18d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7093,7 +7093,6 @@ dependencies = [ "mcp-proxy", "network-scanner", "network-scanner-proto", - "picky", "proxy-socks", "rstest", "serde", @@ -7103,7 +7102,6 @@ dependencies = [ "sysevent-winevent", "tempfile", "test-utils", - "time", "tokio 1.52.3", "tokio-rustls", "tokio-tungstenite 0.26.2", diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 7a13caf4d..cb4cf9278 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -38,12 +38,10 @@ mcp-proxy.path = "../crates/mcp-proxy" network-scanner = { path = "../crates/network-scanner", features = ["test-utils"] } network-scanner-proto = { path = "../crates/network-scanner-proto" } rstest = "0.25" -picky = { version = "7.0.0-rc.15", default-features = false, features = ["jose"] } serde_json = "1" sysevent.path = "../crates/sysevent" tempfile = "3" test-utils.path = "../crates/test-utils" -time = "0.3" tokio-rustls = { version = "0.26", features = ["ring"] } diff --git a/testsuite/src/dgw_config.rs b/testsuite/src/dgw_config.rs index 47729093b..f98d2f770 100644 --- a/testsuite/src/dgw_config.rs +++ b/testsuite/src/dgw_config.rs @@ -71,12 +71,6 @@ pub struct DgwConfig { /// Pass a path that does not yet exist to test behaviour before the folder is created. #[builder(default, setter(into))] recording_path: Option, - /// Base64-encoded DER provisioner public key. - /// - /// When `Some`, replaces the default test provisioner key. Used by tests that - /// need to sign JWTs with a matching private key (e.g. enrollment tests). - #[builder(default, setter(into))] - provisioner_public_key_base64: Option, /// Agent tunnel (QUIC) configuration. #[builder(default, setter(into))] agent_tunnel: Option, @@ -112,7 +106,6 @@ impl DgwConfigHandle { ai_gateway, enable_unstable, recording_path, - provisioner_public_key_base64, agent_tunnel, } = config; @@ -177,9 +170,7 @@ impl DgwConfigHandle { String::new() }; - let provisioner_key_value = provisioner_public_key_base64.unwrap_or_else(|| { - "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB".to_owned() - }); + let provisioner_key_value = "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB"; let agent_tunnel_json = if let Some(at_config) = agent_tunnel { let listen_port = at_config.listen_port.unwrap_or_else(find_unused_port); diff --git a/testsuite/tests/cli/agent/up.rs b/testsuite/tests/cli/agent/up.rs index e1b207a05..a9ee50454 100644 --- a/testsuite/tests/cli/agent/up.rs +++ b/testsuite/tests/cli/agent/up.rs @@ -17,12 +17,12 @@ fn make_jwt(payload: serde_json::Value) -> String { ) } -fn sample_jwt() -> String { +fn sample_jwt(jet_gw_url: &str) -> String { make_jwt(serde_json::json!({ - "scope": "gateway.tunnel.enroll", + "scope": "gateway.agent.enroll", "exp": 1_999_999_999i64, "jti": "00000000-0000-0000-0000-000000000000", - "jet_gw_url": "https://gateway.example.com:7171", + "jet_gw_url": jet_gw_url, "jet_agent_name": "site-a-agent", })) } @@ -32,7 +32,7 @@ fn sample_jwt() -> String { /// reading works end-to-end. #[test] fn up_enrollment_string_from_stdin() { - let jwt = sample_jwt(); + let jwt = sample_jwt("https://gateway.example.com:7171"); let output = agent_assert_cmd() .args(["up", "--enrollment-string", "-"]) @@ -68,58 +68,19 @@ fn up_enrollment_string_stdin_empty_is_error() { ); } -/// Enrollment against a real Gateway with a properly signed JWT. +/// Enrollment against a real Gateway with token validation disabled. /// -/// Starts a Gateway with agent tunnel enabled, signs a JWT with the -/// matching provisioner key, and runs `devolutions-agent up` via stdin. -/// The enrollment should succeed (HTTP 200 from the Gateway). +/// Starts a Gateway with agent tunnel enabled and token validation off, +/// builds a sample JWT pointing at the real Gateway URL, and runs +/// `devolutions-agent up` via stdin. The enrollment should succeed. #[tokio::test] async fn up_enrollment_against_real_gateway() { use anyhow::Context as _; - use picky::jose::jws::JwsAlg; - use picky::jose::jwt::CheckedJwtSig; - use picky::key::PrivateKey; use testsuite::cli::{agent_assert_cmd, dgw_tokio_cmd, wait_for_tcp_port}; use testsuite::dgw_config::{AgentTunnelConfig, DgwConfig}; - // Hardcoded test provisioner key (same as in devolutions-gateway/tests/token_security.rs). - // Security is not required for these tests; we just need a valid key pair. - const PROVISIONER_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDkrPiL/5dmGIT5 -/KuC3H/jIjeLoLoddsLhAlikO5JQQo3Zs71GwT4Wd2z8WLMe0lVZu/Jr2S28p0M8 -F3Lnz4IgzjocQomFgucFWWQRyD03ZE2BHfEeelFsp+/4GZaM6lKZauYlIMtjR1vD -lflgvxNTr0iaii4JR9K3IKCunCRy1HQYPcZ9waNtlG5xXtW9Uf1tLWPJpP/3I5HL -M85JPBv4r286vpeUlfQIa/NB4g5w6KZ6MfEAIU4KeEQpeLAyyYvwUzPR2uQZ4y4I -4Nj84dWYB1cMTlSGugvSgOFKYit1nwLGeA7EevVYPbILRfSMBU/+avGNJJ8HCaaq -FIyY42W9AgMBAAECggEBAImsGXcvydaNrIFUvW1rkxML5qUJfwN+HJWa9ALsWoo3 -h28p5ypR7S9ZdyP1wuErgHcl0C1d80tA6BmlhGhLZeyaPCIHbQQUa0GtL7IE+9X9 -bSvu+tt+iMcB1FdqEFmGOXRkB2sS82Ax9e0qvZihcOFRBkUEK/MqapIV8qctGkSG -wIE6yn5LHRls/fJU8BJeeqJmYpuWljipwTkp9hQ7SdRYFLNjwjlz/b0hjmgFs5QZ -LUNMyTHdHtXQHNsf/GayRUAKf5wzN/jru+nK6lMob2Ehfx9/RAfgaDHzy5BNFMj0 -i9+sAycgIW1HpTuDvSEs3qP26NeQ82GbJzATmdAKa4ECgYEA9Vti0YG+eXJI3vdS -uXInU0i1SY4aEG397OlGMwh0yQnp2KGruLZGkTvqxG/Adj1ObDyjFH9XUhMrd0za -Nk/VJFybWafljUPcrfyPAVLQLjsBfMg3Y34sTF6QjUnhg49X2jfvy9QpC5altCtA -46/KVAGREnQJ3wMjfGGIFP8BUZsCgYEA7phYE/cYyWg7a/o8eKOFGqs11ojSqG3y -0OE7kvW2ugUuy3ex+kr19Q/8pOWEc7M1UEV8gmc11xgB70EhIFt9Jq379H0X4ahS -+mgLiPzKAdNCRPpkxwwN9HxFDgGWoYcgMplhoAmg9lWSDuE1Exy8iu5inMWuF4MT -/jG+cLnUZ4cCgYAfMIXIUjDvaUrAJTp73noHSUfaWNkRW5oa4rCMzjdiUwNKCYs1 -yN4BmldGr1oM7dApTDAC7AkiotM0sC1RGCblH2yUIha5NXY5G9Dl/yv9pHyU6zK3 -UBO7hY3kmA611aP6VoACLi8ljPn1hEYUa4VR1n0llmCm29RH/HH7EUuOnwKBgExH -OCFp5eq+AAFNRvfqjysvgU7M/0wJmo9c8obRN1HRRlyWL7gtLuTh74toNSgoKus2 -y8+E35mce0HaOJT3qtMq3FoVhAUIoz6a9NUevBZJS+5xfraEDBIViJ4ps9aANLL4 -hlV7vpICWWeYaDdsAHsKK0yjhjzOEx45GQFA578RAoGBAOB42BG53tL0G9pPeJPt -S2LM6vQKeYx+gXTk6F335UTiiC8t0CgNNQUkW105P/SdpCTTKojAsOPMKOF7z4mL -lj/bWmNq7xu9uVOcBKrboVFGO/n6FXyWZxHPOTdjTkpe8kvvmSwl2iaTNllvSr46 -Z/fDKMxHxeXla54kfV+HiGkH ------END PRIVATE KEY-----"#; - - // Precomputed SPKI DER of PROVISIONER_KEY_PEM, multibase-encoded (prefix 'm' = base64). - const PROVISIONER_PUB_KEY_MULTIBASE: &str = "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Kz4i/+XZhiE+fyrgtx/4yI3i6C6HXbC4QJYpDuSUEKN2bO9RsE+Fnds/FizHtJVWbvya9ktvKdDPBdy58+CIM46HEKJhYLnBVlkEcg9N2RNgR3xHnpRbKfv+BmWjOpSmWrmJSDLY0dbw5X5YL8TU69ImoouCUfStyCgrpwkctR0GD3GfcGjbZRucV7VvVH9bS1jyaT/9yORyzPOSTwb+K9vOr6XlJX0CGvzQeIOcOimejHxACFOCnhEKXiwMsmL8FMz0drkGeMuCODY/OHVmAdXDE5UhroL0oDhSmIrdZ8CxngOxHr1WD2yC0X0jAVP/mrxjSSfBwmmqhSMmONlvQIDAQAB"; - - let priv_key = PrivateKey::from_pem_str(PROVISIONER_KEY_PEM).expect("parse test provisioner key"); - let config_handle = DgwConfig::builder() - .provisioner_public_key_base64(PROVISIONER_PUB_KEY_MULTIBASE.to_owned()) + .disable_token_validation(true) .agent_tunnel(AgentTunnelConfig::builder().build()) .enable_unstable(true) .build() @@ -140,21 +101,9 @@ Z/fDKMxHxeXla54kfV+HiGkH .await .expect("gateway HTTP port ready"); - // Sign a proper enrollment JWT with the provisioner private key. - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - let jwt = CheckedJwtSig::new( - JwsAlg::RS256, - serde_json::json!({ - "scope": "gateway.agent.enroll", - "nbf": now - 60, - "exp": now + 3600, - "jti": "b1e2c3d4-5678-9abc-def0-123456789abc", - "jet_gw_url": format!("http://127.0.0.1:{}", config_handle.http_port()), - "jet_agent_name": "test-agent", - }), - ) - .encode(&priv_key) - .expect("sign enrollment JWT"); + // Build a sample JWT pointing at the real Gateway URL. + // Token validation is disabled, so the signature is not checked. + let jwt = sample_jwt(&format!("http://127.0.0.1:{}", config_handle.http_port())); // Run the agent with --enrollment-string - (stdin). // Set DAGENT_CONFIG_PATH so certs are written to a temp directory. From e1c00e4047ed14fd3019431206c471437550b54c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:50:56 +0000 Subject: [PATCH 08/11] refactor(agent): inline provisioner key value, remove extra blank line in Cargo.toml Agent-Logs-Url: https://github.com/Devolutions/devolutions-gateway/sessions/50788e81-abe4-47f8-b841-a9392c3bbe56 Co-authored-by: CBenoit <3809077+CBenoit@users.noreply.github.com> --- testsuite/Cargo.toml | 1 - testsuite/src/dgw_config.rs | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index cb4cf9278..6473a602b 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -44,7 +44,6 @@ tempfile = "3" test-utils.path = "../crates/test-utils" tokio-rustls = { version = "0.26", features = ["ring"] } - [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/src/dgw_config.rs b/testsuite/src/dgw_config.rs index f98d2f770..038d5dd18 100644 --- a/testsuite/src/dgw_config.rs +++ b/testsuite/src/dgw_config.rs @@ -170,8 +170,6 @@ impl DgwConfigHandle { String::new() }; - let provisioner_key_value = "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB"; - let agent_tunnel_json = if let Some(at_config) = agent_tunnel { let listen_port = at_config.listen_port.unwrap_or_else(find_unused_port); format!( @@ -189,7 +187,7 @@ impl DgwConfigHandle { let config = format!( r#"{{ "ProvisionerPublicKeyData": {{ - "Value": "{provisioner_key_value}" + "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HgjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" }}, "Listeners": [ {{ From b50a18c38569b5a7e16fa5e15bdb09f4ed990fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 26 May 2026 23:57:42 +0900 Subject: [PATCH 09/11] . --- devolutions-gateway/src/api/tunnel.rs | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index fee203c58..5bcb09119 100644 --- a/devolutions-gateway/src/api/tunnel.rs +++ b/devolutions-gateway/src/api/tunnel.rs @@ -44,6 +44,43 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey) ) } +#[deprecated = "make sure this is never used without a deliberate action"] +mod unsafe_debug { + // Any function in this module should only be used at development stage when deliberately + // enabling debugging options. + + use picky::jose::jws::RawJws; + use picky::jose::jwt::{self, JwtSig}; + + use crate::token::{AccessScope, EnrollmentTokenClaims}; + + /// Dangerous enrollment token validation procedure. + /// + /// Like [`validate_enrollment_jwt`], but skips signature and `exp`/`nbf` checks. + /// + /// Skips signature verification and `exp`/`nbf` checks. Only the scope + /// (`AgentEnroll` or `Wildcard`) is still enforced, so test tokens still + /// have to carry the right intent. + pub fn dangerous_validate_enrollment_jwt(token: &str) -> bool { + warn!( + "**DEBUG OPTION** Using dangerous enrollment token validation for testing purposes. Make sure this is not happening in production!" + ); + + let Ok(jwt) = RawJws::decode(token).map(RawJws::discard_signature).map(JwtSig::from) else { + return false; + }; + + let Ok(validated) = jwt.validate::(&jwt::NO_CHECK_VALIDATOR) else { + return false; + }; + + matches!( + validated.state.claims.scope, + AccessScope::AgentEnroll | AccessScope::Wildcard + ) + } +} + #[derive(Deserialize)] pub struct EnrollRequest { /// Agent-generated UUID (the agent owns its identity). @@ -122,7 +159,14 @@ async fn enroll_agent( .as_ref() .ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?; - if !validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) { + let token_is_valid = if conf.debug.disable_token_validation { + #[allow(deprecated, reason = "properly gated by disable_token_validation debug option")] + unsafe_debug::dangerous_validate_enrollment_jwt(provided_token) + } else { + validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) + }; + + if !token_is_valid { return Err(HttpError::forbidden().msg("invalid enrollment token")); } From b0dfe81749ffa514aef696a170b8b1bb09827564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 27 May 2026 00:48:35 +0900 Subject: [PATCH 10/11] . --- devolutions-gateway/src/api/tunnel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devolutions-gateway/src/api/tunnel.rs b/devolutions-gateway/src/api/tunnel.rs index 5bcb09119..0d8656bda 100644 --- a/devolutions-gateway/src/api/tunnel.rs +++ b/devolutions-gateway/src/api/tunnel.rs @@ -61,7 +61,7 @@ mod unsafe_debug { /// Skips signature verification and `exp`/`nbf` checks. Only the scope /// (`AgentEnroll` or `Wildcard`) is still enforced, so test tokens still /// have to carry the right intent. - pub fn dangerous_validate_enrollment_jwt(token: &str) -> bool { + pub(super) fn dangerous_validate_enrollment_jwt(token: &str) -> bool { warn!( "**DEBUG OPTION** Using dangerous enrollment token validation for testing purposes. Make sure this is not happening in production!" ); From d87faecc971abdfb76fcb8f5bc5a02a192755bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Wed, 27 May 2026 22:59:32 +0900 Subject: [PATCH 11/11] Apply suggestion from @CBenoit --- package/AgentWindowsManaged/Program.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index daa16fef1..2d9ead71c 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -349,11 +349,6 @@ static void Main() // - Make DevolutionsDesktopAgent answer WM_CLOSE projectProperties.Add(new Property("MSIRESTARTMANAGERCONTROL", "Disable")); - // Prevent the enrollment JWT from being logged in verbose MSI logs (/L*v). - // `Hidden = true` only suppresses the property table dump; MsiHiddenProperties - // controls masking of CustomActionData expansion in verbose logs. - projectProperties.Add(new Property("MsiHiddenProperties", AgentProperties.AgentTunnelEnrollmentString)); - // Agent tunnel properties: must be declared Secure so the values set in the wizard UI // survive the UAC boundary and reach the deferred CA via CustomActionData. projectProperties.Add(new Property(AgentProperties.AgentTunnelEnrollmentString, "") { Hidden = true, Secure = true });