diff --git a/Cargo.lock b/Cargo.lock index 7f8907cce..84fbea799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,9 +1772,11 @@ dependencies = [ "rand 0.8.7", "rcgen", "reqwest", + "ring 0.17.14", "rustls 0.23.43", "rustls-pemfile 2.2.0", "rustls-pki-types", + "rustls-webpki", "serde", "serde_json", "sha2 0.10.9", @@ -7649,6 +7651,7 @@ dependencies = [ "assert_cmd", "base64 0.23.1", "camino", + "devolutions-agent", "devolutions-gateway", "devolutions-gateway-task", "dynosaur", @@ -7664,10 +7667,12 @@ dependencies = [ "network-scanner-proto", "nonempty", "picky", + "prost-types", "proxy-socks", "quinn", "rcgen", "reqwest", + "ring 0.17.14", "rstest", "rustls 0.23.43", "rustls-pemfile 2.2.0", @@ -7680,14 +7685,18 @@ dependencies = [ "sysevent-winevent", "tempfile", "test-utils", + "time", "tokio 1.52.3", "tokio-rustls", + "tokio-stream", "tokio-tungstenite", "tokio-util", + "tonic 0.12.3", "typed-builder", "uuid", "win-api-wrappers", "windows 0.61.3", + "x509-parser", ] [[package]] diff --git a/crates/devolutions-agent-shared/src/lib.rs b/crates/devolutions-agent-shared/src/lib.rs index c75c429fe..97ea52c83 100644 --- a/crates/devolutions-agent-shared/src/lib.rs +++ b/crates/devolutions-agent-shared/src/lib.rs @@ -12,7 +12,7 @@ use std::env; use camino::Utf8PathBuf; use cfg_if::cfg_if; pub use date_version::{DateVersion, DateVersionError}; -pub use restricted_file::write_restricted_file; +pub use restricted_file::{create_restricted_directory, write_restricted_file, write_restricted_file_atomic}; pub use update_manifest::{ InstalledProductUpdateInfo, ProductUpdateInfo, ProductUpdateInfoV1, UPDATE_MANIFEST_V2_MINOR_VERSION, UpdateManifest, UpdateManifestV1, UpdateManifestV2, UpdateProductKey, UpdateSchedule, VersionMajorV2, diff --git a/crates/devolutions-agent-shared/src/restricted_file.rs b/crates/devolutions-agent-shared/src/restricted_file.rs index c2e4bc796..f506cc4fe 100644 --- a/crates/devolutions-agent-shared/src/restricted_file.rs +++ b/crates/devolutions-agent-shared/src/restricted_file.rs @@ -1,6 +1,80 @@ use anyhow::Context as _; use camino::Utf8Path; +pub fn write_restricted_file_atomic(path: &Utf8Path, contents: &str) -> anyhow::Result<()> { + use std::io::Write as _; + + let parent = path.parent().context("restricted file requires a parent directory")?; + let mut temporary = tempfile::Builder::new() + .prefix(".identity-") + .make_in(parent, |candidate| { + let candidate = Utf8Path::from_path(candidate) + .ok_or_else(|| std::io::Error::other("non-UTF-8 restricted file path"))?; + create_restricted_file(candidate).map_err(std::io::Error::other) + }) + .context("create restricted temporary file")?; + temporary + .write_all(contents.as_bytes()) + .context("write restricted temporary file")?; + temporary + .as_file() + .sync_all() + .context("sync restricted temporary file")?; + temporary + .into_temp_path() + .persist(path) + .map_err(|error| error.error) + .context("replace restricted file")?; + #[cfg(unix)] + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .context("sync restricted file directory")?; + Ok(()) +} + +pub fn create_restricted_directory(path: &Utf8Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("create restricted directory parent")?; + } + match std::fs::symlink_metadata(path) { + Ok(metadata) => anyhow::ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "restricted directory must be a real directory" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("inspect restricted directory"), + } + + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; + if !path.exists() { + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .context("create restricted directory")?; + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .context("restrict directory permissions")?; + } + #[cfg(windows)] + { + use win_api_wrappers::raw::Win32::Security::Authorization::SE_FILE_OBJECT; + use win_api_wrappers::security::acl::set_named_security_info; + use win_api_wrappers::str::U16CString; + let attributes = restricted_attributes(true)?; + if !path.exists() { + win_api_wrappers::fs::create_directory(path.as_std_path(), Some(&attributes)) + .context("create restricted directory")?; + } + let name = U16CString::from_str(path.as_str()).context("encode restricted directory path")?; + let dacl = restricted_dacl(true)?; + set_named_security_info(&name, SE_FILE_OBJECT, None, None, Some(&dacl), None) + .context("restrict directory permissions")?; + } + Ok(()) +} + pub fn write_restricted_file(path: &Utf8Path, contents: &str) -> anyhow::Result<()> { use std::io::Write as _; @@ -28,6 +102,13 @@ fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { #[cfg(windows)] fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { + let attributes = restricted_attributes(false)?; + win_api_wrappers::fs::create_file(path.as_std_path(), Some(&attributes)) + .with_context(|| format!("create file {path}")) +} + +#[cfg(windows)] +fn restricted_dacl(inherit: bool) -> anyhow::Result { use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::raw::Win32::Security; use win_api_wrappers::raw::Win32::Security::Authorization::GRANT_ACCESS; @@ -35,7 +116,6 @@ fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { DELETE, FILE_ALL_ACCESS, FILE_GENERIC_READ, FILE_GENERIC_WRITE, }; use win_api_wrappers::security::acl::{Acl, ExplicitAccess, InheritableAcl, InheritableAclKind, Trustee}; - use win_api_wrappers::security::attributes::SecurityAttributesInit; use win_api_wrappers::token::Token; let modify = FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0 | DELETE.0; @@ -43,7 +123,11 @@ fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { let entry = |access_permissions, sid| ExplicitAccess { access_permissions, access_mode: GRANT_ACCESS, - inheritance: Security::NO_INHERITANCE, + inheritance: if inherit { + Security::OBJECT_INHERIT_ACE | Security::CONTAINER_INHERIT_ACE + } else { + Security::NO_INHERITANCE + }, trustee: Trustee::Sid(sid), }; @@ -62,19 +146,21 @@ fn create_restricted_file(path: &Utf8Path) -> anyhow::Result { ), ]; - let dacl = InheritableAcl { + Ok(InheritableAcl { kind: InheritableAclKind::Protected, acl: Acl::new() .and_then(|acl| acl.set_entries(&entries)) .context("build restricted DACL")?, - }; + }) +} + +#[cfg(windows)] +fn restricted_attributes(inherit: bool) -> anyhow::Result { + use win_api_wrappers::security::attributes::SecurityAttributesInit; - let attributes = SecurityAttributesInit { - dacl: Some(dacl), + Ok(SecurityAttributesInit { + dacl: Some(restricted_dacl(inherit)?), ..Default::default() } - .init(); - - win_api_wrappers::fs::create_file(path.as_std_path(), Some(&attributes)) - .with_context(|| format!("create file {path}")) + .init()) } diff --git a/devolutions-agent/Cargo.toml b/devolutions-agent/Cargo.toml index d1b942405..3992f59be 100644 --- a/devolutions-agent/Cargo.toml +++ b/devolutions-agent/Cargo.toml @@ -35,10 +35,12 @@ prost-types = "0.13" quinn = "0.11" rand = "0.8" # FIXME(@CBenoit): maybe we don't need this crate rcgen = { version = "0.13", features = ["pem"] } +ring = "0.17" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "http2", "socks", "json"] } rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } rustls-pemfile = "2.2" rustls-pki-types = "1" +rustls-webpki = { version = "0.103", default-features = false, features = ["std", "ring"] } sha2 = "0.10" serde_json = "1" serde = { version = "1", features = ["derive"] } diff --git a/devolutions-agent/docker/devolutions-agent-entrypoint.sh b/devolutions-agent/docker/devolutions-agent-entrypoint.sh index dfd6f2e0c..90996f30c 100644 --- a/devolutions-agent/docker/devolutions-agent-entrypoint.sh +++ b/devolutions-agent/docker/devolutions-agent-entrypoint.sh @@ -7,7 +7,42 @@ json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } -if [ -n "${PSU_SERVER_URL:-}" ] && [ -n "${PSU_APP_TOKEN:-}" ]; then +if [ -n "${PSU_DEVICE_ROOT_THUMBPRINT:-}" ]; then + if [ -z "${PSU_SERVER_URL:-}" ] || [ -z "${PSU_DEVICE_STATE_DIRECTORY:-}" ]; then + echo "PSU device mode requires PSU_SERVER_URL and PSU_DEVICE_STATE_DIRECTORY on a persistent writable volume" >&2 + exit 1 + fi + if [ -n "${PSU_APP_TOKEN:-}" ]; then + echo "PSU_APP_TOKEN must not be set in PSU device mode" >&2 + exit 1 + fi + token_config='' + if [ -n "${PSU_ENROLLMENT_TOKEN:-}" ]; then + token_config=", \"EnrollmentToken\": \"$(json_escape "${PSU_ENROLLMENT_TOKEN}")\"" + fi + hardware_config='' + if [ -n "${PSU_DEVICE_HARDWARE_ID:-}" ]; then + hardware_config=", \"HardwareId\": \"$(json_escape "${PSU_DEVICE_HARDWARE_ID}")\"" + fi + psu_agent_config=$(cat <&2 + exit 1 +elif [ -n "${PSU_SERVER_URL:-}" ] && [ -n "${PSU_APP_TOKEN:-}" ]; then psu_agent_config=$(cat < "${DAGENT_CONFIG_PATH}/agent.json" <) -> anyhow::Result for Option`. /// Illegal states are made unrepresentable: a disabled PSU agent is `None`, and an -/// enabled one is `Some(PsuConf)` with all required fields (server URL and application -/// token) guaranteed present, and the server URL uses the `http` or `https` scheme. +/// enabled one has an HTTP(S) server URL and exactly one configured authentication +/// mode: a legacy application token or pinned device enrollment. #[derive(Clone)] pub struct PsuConf { pub server_url: Url, pub agent_id: Option, pub display_name: Option, pub app_token: String, + pub device_enrollment: Option, pub powershell: dto::PsuPowerShellConf, } @@ -234,6 +235,7 @@ impl std::fmt::Debug for PsuConf { .field("agent_id", &self.agent_id) .field("display_name", &self.display_name) .field("app_token", &REDACTED) + .field("device_enrollment", &self.device_enrollment) .field("powershell", &self.powershell) .finish() } @@ -270,16 +272,56 @@ impl TryFrom for Option { "unsupported PSU agent ServerUrl scheme `{}`: expected http or https", server_url.scheme() ); - let app_token = conf - .app_token - .filter(|token| !token.trim().is_empty()) - .context("PSU agent enabled but AppToken is not configured")?; + let app_token = if let Some(device) = &conf.device_enrollment { + anyhow::ensure!( + conf.app_token.is_none(), + "PsuAgent.AppToken must be omitted in device mode" + ); + anyhow::ensure!( + device.root_thumbprint.len() == 64 + && device.root_thumbprint.bytes().all(|byte| byte.is_ascii_hexdigit()), + "PsuAgent.DeviceEnrollment.RootThumbprint must be a SHA-256 hexadecimal fingerprint" + ); + anyhow::ensure!( + server_url.username().is_empty() + && server_url.password().is_none() + && server_url.query().is_none() + && server_url.fragment().is_none(), + "PSU device ServerUrl must not contain credentials, query or fragment" + ); + if let Some(token) = &device.enrollment_token { + anyhow::ensure!( + !token.trim().is_empty() && token.len() <= 16 * 1024, + "PSU enrollment token must contain between 1 and 16384 bytes" + ); + } + if let Some(hardware_id) = &device.hardware_id { + anyhow::ensure!( + !hardware_id.trim().is_empty() + && hardware_id.len() <= 256 + && !hardware_id.chars().any(char::is_control), + "invalid PSU device HardwareId" + ); + } + if let Some(directory) = &device.state_directory { + anyhow::ensure!( + !directory.as_str().trim().is_empty(), + "PSU device StateDirectory must not be empty" + ); + } + String::new() + } else { + conf.app_token + .filter(|token| !token.trim().is_empty()) + .context("PSU agent enabled but AppToken is not configured")? + }; Ok(Some(PsuConf { server_url, agent_id: conf.agent_id, display_name: conf.display_name, app_token, + device_enrollment: conf.device_enrollment, powershell: conf.powershell, })) } @@ -744,10 +786,13 @@ pub mod dto { #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, - /// PSU application token used to authenticate the agent. Required when enabled. + /// PSU application token used in legacy mode. Omit in device mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub app_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_enrollment: Option, + /// PowerShell child process configuration. #[serde( default, @@ -765,11 +810,35 @@ pub mod dto { .field("agent_id", &self.agent_id) .field("display_name", &self.display_name) .field("app_token", &self.app_token.as_ref().map(|_| REDACTED)) + .field("device_enrollment", &self.device_enrollment) .field("powershell", &self.powershell) .finish() } } + #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase", deny_unknown_fields)] + pub struct PsuDeviceEnrollmentConf { + pub root_thumbprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enrollment_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hardware_id: Option, + } + + impl std::fmt::Debug for PsuDeviceEnrollmentConf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PsuDeviceEnrollmentConf") + .field("root_thumbprint", &self.root_thumbprint) + .field("enrollment_token", &self.enrollment_token.as_ref().map(|_| REDACTED)) + .field("state_directory", &self.state_directory) + .field("hardware_id", &self.hardware_id) + .finish() + } + } + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct PsuPowerShellConf { diff --git a/devolutions-agent/src/main.rs b/devolutions-agent/src/main.rs index f8c9a048f..ba1c27de9 100644 --- a/devolutions-agent/src/main.rs +++ b/devolutions-agent/src/main.rs @@ -248,6 +248,31 @@ fn main() { eprintln!("[ERROR] Agent configuration failed: {e}"); } } + "psu-device" => { + let result = (|| -> Result<()> { + let handle = ConfHandle::init()?; + let conf = handle.get_conf(); + let psu = conf.psu_agent.as_ref().context("PSU agent is disabled")?; + match env::args().nth(2).as_deref() { + Some("enroll") => tokio::runtime::Runtime::new() + .context("create enrollment runtime")? + .block_on(devolutions_agent::psu_agent::device::enroll(psu)), + Some("renew") => tokio::runtime::Runtime::new() + .context("create renewal runtime")? + .block_on(devolutions_agent::psu_agent::device::renew(psu)), + Some("status") => { + let identity = devolutions_agent::psu_agent::device::DeviceIdentity::open(psu)?; + println!("{}", identity.status()); + Ok(()) + } + _ => bail!("usage: devolutions-agent psu-device "), + } + })(); + if let Err(error) = result { + eprintln!("[ERROR] PSU device operation failed: {error:#}"); + std::process::exit(1); + } + } "enroll" => { let gateway_url = env::args() .nth(2) diff --git a/devolutions-agent/src/psu_agent/device.rs b/devolutions-agent/src/psu_agent/device.rs new file mode 100644 index 000000000..2e162f461 --- /dev/null +++ b/devolutions-agent/src/psu_agent/device.rs @@ -0,0 +1,665 @@ +use std::fs::{File, OpenOptions}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context as _, bail, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use camino::Utf8PathBuf; +use devolutions_agent_shared::{create_restricted_directory, get_data_dir, write_restricted_file_atomic}; +use ring::rand::SystemRandom; +use ring::signature::{self, EcdsaKeyPair, KeyPair as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tonic::Request; +use tonic::transport::Channel; +use uuid::Uuid; + +use super::protocol::agent_enrollment_client::AgentEnrollmentClient; +use super::protocol::{ + EnrollRequest, EnrollResponse, EnrollmentStatus, RenewRequest, TrustAnchorRequest, TrustAnchorResponse, +}; +use crate::config::PsuConf; + +const RPC_TIMEOUT: Duration = Duration::from_secs(30); +const RENEW_BEFORE: i64 = 7 * 24 * 60 * 60; +const ASSERTION_LIFETIME: i64 = 60; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct State { + version: u32, + server_url: String, + root_thumbprint: String, + hardware_id: String, + key_pkcs8: String, + enrollment_started: bool, + device_id: Option, + certificate: Option, + chain: Vec, + root: Option, + not_after: Option, + pending: bool, +} + +pub struct DeviceIdentity { + path: Utf8PathBuf, + state: State, + _lock: File, +} + +impl DeviceIdentity { + pub fn status(&self) -> serde_json::Value { + serde_json::json!({ + "StatePath": self.path, + "DeviceId": self.state.device_id, + "NotAfter": self.state.not_after, + "PendingApprovalAtIssuance": self.state.pending, + "EnrollmentOutcomeUnknown": self.state.enrollment_started && self.state.device_id.is_none(), + }) + } + + pub fn open(conf: &PsuConf) -> anyhow::Result { + let device = conf + .device_enrollment + .as_ref() + .context("PSU device mode is not configured")?; + let server_url = conf.server_url.as_str().to_owned(); + let namespace = hex::encode(Sha256::digest(server_url.as_bytes())); + let directory = device + .state_directory + .clone() + .unwrap_or_else(|| get_data_dir().join("psu-device")) + .join(namespace); + create_restricted_directory(&directory)?; + let lock_path = directory.join("identity.lock"); + reject_symlink(&lock_path)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .context("open PSU identity lock")?; + lock.try_lock() + .context("another process owns this PSU device identity")?; + let path = directory.join("identity.json"); + reject_symlink(&path)?; + let state = match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes).context("invalid PSU device state")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let key = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).context("generate PSU device key")?; + State { + version: 1, + server_url: server_url.clone(), + root_thumbprint: device.root_thumbprint.to_ascii_uppercase(), + hardware_id: device.hardware_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string()), + key_pkcs8: STANDARD.encode(key.serialize_der()), + enrollment_started: false, + device_id: None, + certificate: None, + chain: Vec::new(), + root: None, + not_after: None, + pending: false, + } + } + Err(error) => return Err(error).context("read PSU device state"), + }; + ensure!(state.version == 1, "unsupported PSU device state version"); + ensure!( + state.server_url == server_url, + "PSU device state belongs to a different server" + ); + ensure!( + state.root_thumbprint == device.root_thumbprint.to_ascii_uppercase(), + "PSU device state root pin changed; explicit operator recovery is required" + ); + ensure!( + !state.hardware_id.is_empty() && state.hardware_id.len() <= 256, + "invalid persisted PSU hardware identifier" + ); + if let Some(hardware_id) = &device.hardware_id { + ensure!( + hardware_id == &state.hardware_id, + "PSU hardware ID changed; explicit operator recovery is required" + ); + } + let identity = Self { + path, + state, + _lock: lock, + }; + identity.key()?; + if identity.state.device_id.is_some() { + identity.validate_saved_certificate()?; + } else { + ensure!( + identity.state.certificate.is_none() + && identity.state.root.is_none() + && identity.state.not_after.is_none() + && identity.state.chain.is_empty(), + "incomplete PSU device state" + ); + } + identity.save()?; + Ok(identity) + } + + fn save(&self) -> anyhow::Result<()> { + let json = serde_json::to_string(&self.state).context("serialize PSU device state")?; + write_restricted_file_atomic(&self.path, &json).context("save PSU device state") + } + + fn key(&self) -> anyhow::Result { + let bytes = STANDARD + .decode(&self.state.key_pkcs8) + .context("decode PSU device private key")?; + EcdsaKeyPair::from_pkcs8( + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + &bytes, + &SystemRandom::new(), + ) + .map_err(|_| anyhow::anyhow!("invalid PSU device P-256 private key")) + } + + pub fn device_id(&self) -> anyhow::Result { + self.state + .device_id + .map(|id| id.to_string()) + .context("PSU device is not enrolled") + } + + fn csr(&self, name: &str) -> anyhow::Result> { + let bytes = STANDARD + .decode(&self.state.key_pkcs8) + .context("decode PSU device private key")?; + let key = rcgen::KeyPair::try_from(bytes).context("load PSU device key for CSR")?; + let mut params = rcgen::CertificateParams::default(); + params.distinguished_name.push(rcgen::DnType::CommonName, name); + Ok(params + .serialize_request(&key) + .context("create PSU device CSR")? + .der() + .to_vec()) + } + + pub async fn prepare(&mut self, channel: Channel, conf: &PsuConf, machine_name: &str) -> anyhow::Result<()> { + let mut client = AgentEnrollmentClient::new(channel); + if self.state.device_id.is_none() { + ensure!( + !self.state.enrollment_started, + "PSU enrollment outcome is unknown; recover the existing identity with the operator, do not enroll again" + ); + let nonce = Uuid::new_v4().to_string(); + let mut request = Request::new(TrustAnchorRequest { nonce: nonce.clone() }); + request.set_timeout(RPC_TIMEOUT); + let response = client + .get_trust_anchor(request) + .await + .context("get PSU trust anchor")? + .into_inner(); + validate_trust_anchor(&response, &self.state.root_thumbprint, &nonce, unix_now()?)?; + let root = response.root_certificate; + + let token = conf + .device_enrollment + .as_ref() + .and_then(|device| device.enrollment_token.as_deref()) + .context("PSU first enrollment requires DeviceEnrollment.EnrollmentToken")?; + let mut request = Request::new(EnrollRequest { + csr: self.csr(machine_name)?, + machine_name: machine_name.to_owned(), + os: std::env::consts::OS.to_owned(), + architecture: std::env::consts::ARCH.to_owned(), + agent_version: env!("CARGO_PKG_VERSION").to_owned(), + requested_display_name: conf.display_name.clone().unwrap_or_else(|| machine_name.to_owned()), + hardware_id: self.state.hardware_id.clone(), + }); + request.metadata_mut().insert( + "authorization", + format!("Bearer {token}") + .parse() + .context("invalid PSU enrollment token metadata")?, + ); + request.set_timeout(RPC_TIMEOUT); + // Persist before sending: an ambiguous response must never automatically spend the token again. + self.state.enrollment_started = true; + self.save()?; + let response = client.enroll(request).await.context("enroll PSU device")?.into_inner(); + self.replace_certificate(response, root)?; + return Ok(()); + } + self.renew_if_needed(&mut client, false).await + } + + async fn renew_if_needed( + &mut self, + client: &mut AgentEnrollmentClient, + force: bool, + ) -> anyhow::Result<()> { + let now = unix_now()?; + if !force && now < self.renewal_at()? { + return Ok(()); + } + let id = self.device_id()?; + let mut request = Request::new(RenewRequest { csr: self.csr(&id)? }); + request.metadata_mut().insert( + "authorization", + self.authorization()?.parse().context("invalid device metadata")?, + ); + request.set_timeout(RPC_TIMEOUT); + let response = client + .renew(request) + .await + .context("renew PSU device certificate")? + .into_inner(); + ensure!(response.device_id == id, "PSU renewal changed device identity"); + let root = STANDARD + .decode(self.state.root.as_ref().context("missing PSU device root")?) + .context("decode PSU device root")?; + let device_id = self.state.device_id.context("missing PSU device ID")?; + self.replace_certificate(response, root)?; + info!(%device_id, "Renewed PSU device certificate"); + Ok(()) + } + + fn replace_certificate(&mut self, response: EnrollResponse, root: Vec) -> anyhow::Result<()> { + let device_id = Uuid::parse_str(&response.device_id).context("invalid PSU device ID")?; + if let Some(existing) = self.state.device_id { + ensure!(existing == device_id, "PSU renewal changed device identity"); + } + let pending = match EnrollmentStatus::try_from(response.status) { + Ok(EnrollmentStatus::Approved) => false, + Ok(EnrollmentStatus::PendingApproval) => true, + _ => bail!("invalid PSU certificate approval status"), + }; + validate_device_certificate( + &response.certificate, + &response.chain, + &root, + &self.state.root_thumbprint, + self.key()?.public_key().as_ref(), + &device_id.to_string(), + unix_now()?, + )?; + ensure!( + fingerprint(&response.certificate) == response.certificate_thumbprint, + "PSU device certificate thumbprint mismatch" + ); + let expiry = certificate_expiry(&response.certificate)?; + let timestamp = response.not_after.context("missing PSU certificate expiry")?; + ensure!( + timestamp.seconds == expiry && timestamp.nanos == 0, + "PSU certificate expiry mismatch" + ); + let old = self.state.clone(); + self.state.device_id = Some(device_id); + self.state.certificate = Some(STANDARD.encode(response.certificate)); + self.state.chain = response.chain.into_iter().map(|cert| STANDARD.encode(cert)).collect(); + self.state.root = Some(STANDARD.encode(root)); + self.state.not_after = Some(expiry); + self.state.pending = pending; + if let Err(error) = self.save() { + self.state = old; + return Err(error); + } + info!(%device_id, pending, "Persisted PSU device identity"); + Ok(()) + } + + fn validate_saved_certificate(&self) -> anyhow::Result<()> { + let certificate = STANDARD + .decode( + self.state + .certificate + .as_ref() + .context("missing PSU device certificate")?, + ) + .context("decode PSU device certificate")?; + let root = STANDARD + .decode(self.state.root.as_ref().context("missing PSU device root")?) + .context("decode PSU device root")?; + let chain = self + .state + .chain + .iter() + .map(|cert| STANDARD.decode(cert)) + .collect::, _>>() + .context("decode PSU device chain")?; + let expiry = certificate_expiry(&certificate)?; + ensure!( + self.state.not_after == Some(expiry), + "persisted PSU certificate expiry mismatch" + ); + // Expired leaf certificates remain usable only as proof for Renew. + let at = unix_now()?.min(expiry - 1); + validate_device_certificate( + &certificate, + &chain, + &root, + &self.state.root_thumbprint, + self.key()?.public_key().as_ref(), + &self.device_id()?, + at, + ) + } + + pub fn authorization(&self) -> anyhow::Result { + let id = self.device_id()?; + let now = unix_now()?; + let certificate = self + .state + .certificate + .as_ref() + .context("missing PSU device certificate")?; + let header = serde_json::json!({ "alg": "ES256", "typ": "JWT", "x5c": [certificate] }); + let claims = serde_json::json!({ + "aud": "psu-agent", "sub": id, "iat": now, "nbf": now - 5, + "exp": now + ASSERTION_LIFETIME, "jti": Uuid::new_v4().to_string() + }); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?) + ); + let signature = self + .key()? + .sign(&SystemRandom::new(), input.as_bytes()) + .map_err(|_| anyhow::anyhow!("sign PSU device assertion"))?; + Ok(format!("Device {input}.{}", URL_SAFE_NO_PAD.encode(signature.as_ref()))) + } + + pub fn renewal_delay(&self) -> anyhow::Result { + let remaining = (self.renewal_at()? - unix_now()?).max(1); + Ok(Duration::from_secs( + u64::try_from(remaining).context("invalid renewal deadline")?, + )) + } + + fn renewal_at(&self) -> anyhow::Result { + let certificate = STANDARD.decode( + self.state + .certificate + .as_ref() + .context("missing PSU device certificate")?, + )?; + let (_, cert) = + x509_parser::parse_x509_certificate(&certificate).map_err(|_| anyhow::anyhow!("parse PSU certificate"))?; + let expiry = cert.validity().not_after.timestamp(); + let lifetime = expiry - cert.validity().not_before.timestamp(); + ensure!(lifetime > 0, "invalid PSU certificate lifetime"); + Ok(expiry - RENEW_BEFORE.min((lifetime / 5).max(1))) + } +} + +pub async fn enroll(conf: &PsuConf) -> anyhow::Result<()> { + let mut identity = DeviceIdentity::open(conf)?; + identity + .prepare(connect_enrollment(conf).await?, conf, &super::machine_name()) + .await +} + +pub async fn renew(conf: &PsuConf) -> anyhow::Result<()> { + let mut identity = DeviceIdentity::open(conf)?; + identity.device_id()?; + let mut client = AgentEnrollmentClient::new(connect_enrollment(conf).await?); + identity.renew_if_needed(&mut client, true).await +} + +async fn connect_enrollment(conf: &PsuConf) -> anyhow::Result { + let settings = super::ConnectionSettings::default(); + let endpoint = super::psu_endpoint(conf.server_url.as_str(), &settings)?; + tokio::time::timeout(settings.connect_timeout, endpoint.connect()) + .await + .context("timed out connecting PSU enrollment endpoint")? + .context("connect PSU enrollment endpoint") +} + +fn reject_symlink(path: &camino::Utf8Path) -> anyhow::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "PSU state must be a regular file" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("inspect PSU state file"), + } + Ok(()) +} + +pub fn unix_now() -> anyhow::Result { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock predates Unix epoch")? + .as_secs(), + ) + .context("system clock exceeds supported range") +} + +pub fn fingerprint(bytes: &[u8]) -> String { + hex::encode_upper(Sha256::digest(bytes)) +} + +#[derive(Deserialize)] +struct AssertionHeader { + alg: String, + x5c: Vec, + #[serde(default)] + crit: Vec, +} + +#[derive(Deserialize)] +struct ServerClaims { + aud: String, + nonce: String, + iat: i64, + nbf: i64, + exp: i64, +} + +pub fn validate_trust_anchor(response: &TrustAnchorResponse, pin: &str, nonce: &str, now: i64) -> anyhow::Result<()> { + ensure!( + fingerprint(&response.root_certificate) == pin.to_ascii_uppercase(), + "PSU root pin mismatch" + ); + ensure!( + response.root_thumbprint == fingerprint(&response.root_certificate), + "PSU root fingerprint mismatch" + ); + let parts = response.server_assertion.split('.').collect::>(); + ensure!(parts.len() == 3, "invalid PSU server assertion"); + let header: AssertionHeader = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).context("decode server header")?) + .context("invalid PSU server assertion header")?; + ensure!( + header.alg == "ES256" && header.crit.is_empty(), + "unsupported PSU server assertion algorithm or critical header" + ); + ensure!( + (2..=8).contains(&header.x5c.len()), + "PSU server assertion must include its issuing chain" + ); + let chain = header + .x5c + .iter() + .map(|cert| STANDARD.decode(cert)) + .collect::, _>>() + .context("decode PSU server assertion chain")?; + ensure!( + chain.last() == Some(&response.root_certificate), + "PSU server assertion chain root mismatch" + ); + validate_chain(&chain[0], &chain[1..], &response.root_certificate, now, true)?; + let (_, leaf) = + x509_parser::parse_x509_certificate(&chain[0]).map_err(|_| anyhow::anyhow!("parse PSU server certificate"))?; + let signature = URL_SAFE_NO_PAD + .decode(parts[2]) + .context("decode server assertion signature")?; + signature::UnparsedPublicKey::new( + &signature::ECDSA_P256_SHA256_FIXED, + leaf.public_key().subject_public_key.data.as_ref(), + ) + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) + .map_err(|_| anyhow::anyhow!("invalid PSU server assertion signature"))?; + let claims: ServerClaims = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).context("decode server claims")?) + .context("invalid PSU server assertion claims")?; + ensure!( + claims.aud == "psu-agent-enrollment" && claims.nonce == nonce && !nonce.is_empty(), + "PSU server assertion audience or nonce mismatch" + ); + ensure!( + claims.exp > now + && claims.nbf <= now + 120 + && claims.iat <= now + 120 + && claims + .exp + .checked_sub(claims.iat) + .is_some_and(|lifetime| (1..=300).contains(&lifetime)) + && claims + .exp + .checked_sub(claims.nbf) + .is_some_and(|lifetime| (1..=420).contains(&lifetime)), + "invalid PSU server assertion lifetime" + ); + Ok(()) +} + +pub fn certificate_expiry(der: &[u8]) -> anyhow::Result { + let (remaining, cert) = + x509_parser::parse_x509_certificate(der).map_err(|_| anyhow::anyhow!("parse PSU certificate"))?; + ensure!(remaining.is_empty(), "trailing PSU certificate data"); + Ok(cert.validity().not_after.timestamp()) +} + +fn validate_chain(leaf: &[u8], chain: &[Vec], root: &[u8], at: i64, server: bool) -> anyhow::Result<()> { + let root_der = rustls_pki_types::CertificateDer::from(root); + let root_cert = x509_parser::parse_x509_certificate(root) + .map_err(|_| anyhow::anyhow!("parse PSU root certificate"))? + .1; + ensure!(root_cert.is_ca(), "PSU trust anchor is not a CA"); + ensure!( + root_cert.validity().not_before.timestamp() <= at && root_cert.validity().not_after.timestamp() > at, + "PSU root certificate is not valid at verification time" + ); + let anchors = [webpki::anchor_from_trusted_cert(&root_der).context("parse PSU trust anchor")?]; + let leaf_der = rustls_pki_types::CertificateDer::from(leaf); + let end_entity = webpki::EndEntityCert::try_from(&leaf_der).context("parse PSU leaf certificate")?; + let intermediates = chain + .iter() + .filter(|cert| cert.as_slice() != root) + .map(|cert| rustls_pki_types::CertificateDer::from(cert.as_slice())) + .collect::>(); + let usage = if server { + webpki::KeyUsage::server_auth() + } else { + webpki::KeyUsage::client_auth() + }; + end_entity + .verify_for_usage( + webpki::ALL_VERIFICATION_ALGS, + &anchors, + &intermediates, + rustls_pki_types::UnixTime::since_unix_epoch(Duration::from_secs( + u64::try_from(at).context("invalid verification time")?, + )), + usage, + None, + None, + ) + .context("verify PSU certificate chain")?; + let (_, cert) = + x509_parser::parse_x509_certificate(leaf).map_err(|_| anyhow::anyhow!("parse PSU leaf certificate"))?; + let eku = cert + .extended_key_usage() + .context("parse PSU certificate EKU")? + .context("PSU leaf certificate requires EKU")?; + ensure!( + if server { + eku.value.server_auth + } else { + eku.value.client_auth + }, + "invalid PSU leaf certificate EKU" + ); + let usage = cert + .key_usage() + .context("parse PSU certificate key usage")? + .context("PSU leaf requires key usage")?; + ensure!( + usage.value.digital_signature() && !cert.is_ca(), + "invalid PSU leaf certificate key usage" + ); + Ok(()) +} + +pub fn validate_device_certificate( + leaf: &[u8], + chain: &[Vec], + root: &[u8], + pin: &str, + public_key: &[u8], + device_id: &str, + at: i64, +) -> anyhow::Result<()> { + ensure!( + fingerprint(root) == pin.to_ascii_uppercase(), + "PSU device root pin mismatch" + ); + ensure!( + !chain.is_empty() && chain.len() <= 8 && chain.last().map(Vec::as_slice) == Some(root), + "invalid PSU device issuing chain" + ); + validate_chain(leaf, chain, root, at, false)?; + let (remaining, cert) = + x509_parser::parse_x509_certificate(leaf).map_err(|_| anyhow::anyhow!("parse PSU device certificate"))?; + ensure!(remaining.is_empty(), "trailing PSU device certificate data"); + ensure!( + cert.public_key().subject_public_key.data.as_ref() == public_key, + "PSU certificate does not match device private key" + ); + ensure!( + cert.subject() + .iter_common_name() + .any(|name| name.as_str().ok() == Some(device_id)), + "PSU certificate device identity mismatch" + ); + let san = cert + .subject_alternative_name() + .context("parse PSU device SAN")? + .context("PSU device certificate requires UUID SAN")?; + let expected = format!("urn:uuid:{device_id}"); + ensure!( + san.value.general_names.iter().any(|name| matches!( + name, x509_parser::extensions::GeneralName::URI(uri) if *uri == expected + )), + "PSU certificate UUID SAN mismatch" + ); + Ok(()) +} + +pub fn retryable(error: &anyhow::Error) -> bool { + if let Some(status) = error.chain().find_map(|cause| cause.downcast_ref::()) { + return matches!(status.code(), tonic::Code::Unavailable | tonic::Code::DeadlineExceeded) + || (status.code() == tonic::Code::PermissionDenied + && status.message() == "The device is waiting for approval."); + } + + error + .chain() + .any(|cause| cause.is::() || cause.is::()) +} + +pub fn terminal_authentication(error: &anyhow::Error) -> bool { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(|status| { + matches!( + status.code(), + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied + ) && status.message() != "The device is waiting for approval." + }) +} diff --git a/devolutions-agent/src/psu_agent/mod.rs b/devolutions-agent/src/psu_agent/mod.rs index 59f595db5..1cf369a4e 100644 --- a/devolutions-agent/src/psu_agent/mod.rs +++ b/devolutions-agent/src/psu_agent/mod.rs @@ -4,6 +4,8 @@ mod process_tree; mod powershell; +pub mod device; + use std::collections::HashMap; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -64,11 +66,17 @@ impl Task for PsuAgentTask { .psu_agent .clone() .context("PSU agent task started but the PSU agent is disabled")?; - let agent = PsuAgent::new(conf).context("failed to initialize PSU agent")?; - agent.run(shutdown_signal).await + run_psu_agent(conf, shutdown_signal).await } } +pub async fn run_psu_agent(conf: PsuConf, shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { + PsuAgent::new(conf) + .context("failed to initialize PSU agent")? + .run(shutdown_signal) + .await +} + /// Timing parameters for the PSU gRPC connection and its reconnection policy. #[derive(Debug, Clone, Copy)] struct ConnectionSettings { @@ -111,6 +119,7 @@ struct PsuConnection { _client: AgentControlClient, outgoing_tx: mpsc::Sender, response_stream: Streaming, + agent_id: String, } #[derive(Debug, Clone)] @@ -149,10 +158,17 @@ impl PsuAgent { async fn run(self, mut shutdown_signal: ShutdownSignal) -> anyhow::Result<()> { const RETRY_MULTIPLIER: f64 = 2.0; + let mut identity = self + .conf + .device_enrollment + .as_ref() + .map(|_| device::DeviceIdentity::open(&self.conf)) + .transpose()?; + if is_plaintext_to_remote_host(&self.conf.server_url) { warn!( url = %self.display_url, - "PSU gRPC agent uses plaintext HTTP to a non-loopback host; the AppToken and job traffic are not encrypted" + "PSU gRPC agent uses plaintext HTTP to a non-loopback host; authentication and job traffic are not encrypted" ); } @@ -168,16 +184,36 @@ impl PsuAgent { // so it must not delay the service shutdown. let connection = tokio::select! { _ = shutdown_signal.wait() => return Ok(()), - connection = self.connect() => connection, + connection = self.connect_authenticated(identity.as_mut()) => connection, }; match connection { Ok(connection) => { let connected_at = Instant::now(); - match self.serve(connection, &mut shutdown_signal).await { + let renewal_delay = identity + .as_ref() + .map(device::DeviceIdentity::renewal_delay) + .transpose()?; + let serve = self.serve(connection, &mut shutdown_signal); + tokio::pin!(serve); + let served = match renewal_delay { + Some(delay) => tokio::select! { + result = &mut serve => result, + _ = tokio::time::sleep(delay) => { + info!("Reconnecting PSU device to renew its certificate"); + Err(anyhow::anyhow!("PSU certificate renewal is due")) + } + }, + None => serve.await, + }; + match served { Ok(()) => return Ok(()), Err(error) => { + if identity.is_some() && device::terminal_authentication(&error) { + return Err(error) + .context("PSU device authorization was withdrawn; operator action is required"); + } warn!(url = %self.display_url, error = format!("{error:#}"), "PSU gRPC agent connection lost") } } @@ -187,6 +223,9 @@ impl PsuAgent { } } Err(error) => { + if identity.is_some() && !device::retryable(&error) { + return Err(error).context("PSU device authentication stopped; operator action is required"); + } warn!(url = %self.display_url, error = format!("{error:#}"), "PSU gRPC agent connection failed") } } @@ -203,9 +242,21 @@ impl PsuAgent { } } + #[cfg(test)] async fn connect(&self) -> anyhow::Result { + self.connect_authenticated(None).await + } + + async fn connect_authenticated( + &self, + identity: Option<&mut device::DeviceIdentity>, + ) -> anyhow::Result { // Resolved on every attempt so a secret vault that is not ready yet, or a rotated secret, is picked up. - let app_token = self.resolve_app_token().await?; + let app_token = if identity.is_none() { + Some(self.resolve_app_token().await?) + } else { + None + }; let endpoint = psu_endpoint(self.conf.server_url.as_str(), &self.settings)?; // `Endpoint::connect_timeout` only bounds the TCP connection; this also bounds the TLS and HTTP/2 handshakes. @@ -213,28 +264,45 @@ impl PsuAgent { .await .with_context(|| format!("timed out connecting PSU gRPC endpoint at {}", self.display_url))? .with_context(|| format!("failed to connect PSU gRPC endpoint at {}", self.display_url))?; + let (authorization, agent_id) = if let Some(identity) = identity { + identity + .prepare(channel.clone(), &self.conf, &self.machine_name) + .await?; + (identity.authorization()?, identity.device_id()?) + } else { + ( + format!("Bearer {}", app_token.context("missing PSU application token")?), + self.agent_id.clone(), + ) + }; let mut client = AgentControlClient::new(channel); let (outgoing_tx, outgoing_rx) = mpsc::channel(256); let powershell_version = get_powershell_version(&self.powershell_executable).await; + let mut registration = self.create_registration_message(powershell_version); + registration.agent_id.clone_from(&agent_id); + if let Some(AgentPayload::RegisterAgent(register)) = &mut registration.payload { + register.agent_id.clone_from(&agent_id); + } outgoing_tx - .send(self.create_registration_message(powershell_version)) + .send(registration) .await .context("failed to queue PSU gRPC agent registration")?; - let request = connect_request(ReceiverStream::new(outgoing_rx), Some(&app_token))?; + let request = connect_request(ReceiverStream::new(outgoing_rx), Some(&authorization))?; let response_stream = tokio::time::timeout(self.settings.stream_start_timeout, client.connect(request)) .await .context("timed out starting PSU gRPC agent stream")? .context("failed to start PSU gRPC agent stream")? .into_inner(); - info!(agent_id = %self.agent_id, url = %self.display_url, "Connected PSU gRPC agent"); + info!(%agent_id, url = %self.display_url, "Connected PSU gRPC agent"); Ok(PsuConnection { _client: client, outgoing_tx, response_stream, + agent_id, }) } @@ -243,6 +311,7 @@ impl PsuAgent { &mut connection.response_stream, &connection.outgoing_tx, shutdown_signal, + &connection.agent_id, ) .await } @@ -252,6 +321,7 @@ impl PsuAgent { messages: &mut S, outgoing_tx: &mpsc::Sender, shutdown_signal: &mut ShutdownSignal, + agent_id: &str, ) -> anyhow::Result<()> where S: Stream> + Unpin, @@ -285,6 +355,7 @@ impl PsuAgent { ®istry, &mut process_tasks, &mut connection_id, + agent_id, ) => Some(result), }; @@ -314,6 +385,7 @@ impl PsuAgent { registry: &ProcessRegistry, process_tasks: &mut JoinSet>, connection_id: &mut String, + agent_id: &str, ) -> anyhow::Result<()> { match message.payload { Some(ServerPayload::RegisterAccepted(accepted)) => { @@ -321,7 +393,7 @@ impl PsuAgent { info!(connection_id = %accepted.connection_id, "PSU gRPC agent registration accepted"); } Some(ServerPayload::StartProcess(start_process)) => { - let agent_id = self.agent_id.clone(); + let agent_id = agent_id.to_owned(); let connection_id = connection_id.clone(); let outgoing_tx = outgoing_tx.clone(); @@ -486,14 +558,13 @@ pub(crate) fn diagnostic(level: &str, message: String) -> AgentDiagnostic { } } -fn connect_request(stream: T, app_token: Option<&str>) -> anyhow::Result> { +fn connect_request(stream: T, authorization: Option<&str>) -> anyhow::Result> { let mut request = Request::new(stream); - if let Some(token) = app_token { - let authorization = format!("Bearer {token}"); + if let Some(authorization) = authorization { request.metadata_mut().insert( "authorization", - MetadataValue::try_from(authorization).context("invalid PSU gRPC AppToken metadata")?, + MetadataValue::try_from(authorization).context("invalid PSU gRPC authentication metadata")?, ); } @@ -622,7 +693,7 @@ mod tests { #[test] fn connect_request_adds_authorization_with_app_token() { - let request = connect_request((), Some("token")).expect("create request"); + let request = connect_request((), Some("Bearer token")).expect("create request"); assert_eq!( request @@ -650,6 +721,7 @@ mod tests { agent_id: Some("agent-01".to_owned()), display_name: None, app_token: app_token.to_owned(), + device_enrollment: None, powershell: dto::PsuPowerShellConf { executable_path: Some("missing-pwsh".into()), ..dto::PsuPowerShellConf::default() @@ -882,7 +954,12 @@ mod tests { let serve = tokio::spawn(async move { agent - .serve_messages(&mut ReceiverStream::new(server_rx), &outgoing_tx, &mut shutdown_signal) + .serve_messages( + &mut ReceiverStream::new(server_rx), + &outgoing_tx, + &mut shutdown_signal, + &agent.agent_id, + ) .await }); diff --git a/package/AgentLinux/README.md b/package/AgentLinux/README.md index e6fc08a26..df129a578 100644 --- a/package/AgentLinux/README.md +++ b/package/AgentLinux/README.md @@ -2,7 +2,8 @@ The container includes `multi-pwsh` and installs PowerShell 7.6.4 for the `devolutions-agent` user at image build time. Its `pwsh` alias resolves to that managed version for `multi-pwsh host` commands. -PowerShell Universal is disabled by default and becomes available when both `PSU_SERVER_URL` and `PSU_APP_TOKEN` are specified. +PowerShell Universal is disabled by default. +Supply `PSU_SERVER_URL` and `PSU_APP_TOKEN` for legacy authentication, or opt in to device enrollment below. When enabled, PSU uses the managed `/usr/local/bin/pwsh` alias. Start the Agent without PowerShell Universal: @@ -33,6 +34,59 @@ For HTTPS, set `PSU_SERVER_URL` to an `https://` URL and trust the PSU server ce | `PSU_DISPLAY_NAME` | `Devolutions Agent Linux` | | `PSU_POWERSHELL_EXECUTABLE` | `/var/lib/devolutions-agent/.pwsh/bin/pwsh` | +## PSU device enrollment + +Build this branch rather than using a published legacy image: + +```powershell +.\package\AgentLinux\BuildAndPublishContainer.ps1 ` + -Platform linux/amd64 -ImageName psu-agent-enrollment ` + -Version local -TagLatest:$false +``` + +PSU must implement the PSU-1337 protocol with the full issuing chain in its signed bootstrap proof. +Obtain the root certificate's SHA-256 DER fingerprint through a separate trusted channel. +Use a short-lived, use-limited token with the `AgentEnrollment` role and purpose, not an ordinary Agent token. +Do not set `PSU_APP_TOKEN` in device mode. +PSU-created job containers keep their existing ordinary Bearer configuration and do not opt in to enrollment. + +Prepare a fresh named volume owned by the image's unprivileged agent user: + +```powershell +docker volume create psu-agent-identity +docker run --rm --user root --entrypoint sh ` + --mount source=psu-agent-identity,target=/identity ` + psu-agent-enrollment:local ` + -c 'chown devolutions-agent:devolutions-agent /identity && chmod 700 /identity' +``` + +Set `PSU_ENROLLMENT_TOKEN` and `PSU_DEVICE_ROOT_THUMBPRINT` in the caller's environment without logging their values, then launch against a disposable local PSU HTTP/2 endpoint: + +```powershell +docker run --rm --name psu-device-smoke ` + --add-host host.docker.internal:host-gateway ` + --mount source=psu-agent-identity,target=/identity ` + --env PSU_SERVER_URL=http://host.docker.internal:5006 ` + --env PSU_DEVICE_ROOT_THUMBPRINT --env PSU_ENROLLMENT_TOKEN ` + --env PSU_DEVICE_STATE_DIRECTORY=/identity ` + psu-agent-enrollment:local +``` + +Use normally validated HTTPS for remote servers; the pinned bootstrap proof neither encrypts HTTP nor prevents an active relay. +After enrollment, remove `PSU_ENROLLMENT_TOKEN` and reuse the same volume and endpoint on restart. +Device mode sends fresh `Device` assertions, retains pending credentials, and renews certificates without the enrollment token. +Revocation or reset never triggers re-enrollment or a Bearer downgrade. +Keep the volume private and do not share it between agents. + +| Device environment variable | Requirement | +| --- | --- | +| `PSU_DEVICE_ROOT_THUMBPRINT` | Required SHA-256 root fingerprint; enables device mode. | +| `PSU_DEVICE_STATE_DIRECTORY` | Required path on a persistent volume writable by the agent user. | +| `PSU_ENROLLMENT_TOKEN` | Required only before the first successful enrollment. | +| `PSU_DEVICE_HARDWARE_ID` | Optional stable provisioning identifier; otherwise a separate installation UUID is persisted. | + +See [the standalone smoke procedure](../AgentWindowsManaged/README.md#testing-psu-device-enrollment-without-installing-a-service) for approval, restart, renewal, and recovery checks. + Stopping a PSU job kills the job process and its process group. Processes that a job script moves to their own session or process group, for example with `setsid`, keep running. diff --git a/package/AgentWindowsManaged.Tests/PsuConfigUpgradeTests.cs b/package/AgentWindowsManaged.Tests/PsuConfigUpgradeTests.cs new file mode 100644 index 000000000..729561831 --- /dev/null +++ b/package/AgentWindowsManaged.Tests/PsuConfigUpgradeTests.cs @@ -0,0 +1,30 @@ +using System.IO; + +using DevolutionsAgent.Actions; + +using Xunit; + +namespace DevolutionsAgent.Installer.Tests; + +public sealed class PsuConfigUpgradeTests +{ + [Theory] + [InlineData("{\"PsuAgent\":{\"ServerUrl\":\"https://psu.example\",\"AppToken\":\"token\"}}", true)] + [InlineData("{\"PsuAgent\":{\"ServerUrl\":\"https://psu.example\",\"DeviceEnrollment\":{\"RootThumbprint\":\"0000000000000000000000000000000000000000000000000000000000000000\"}}}", true)] + [InlineData("{\"PsuAgent\":{\"ServerUrl\":\"https://psu.example\",\"DeviceEnrollment\":{}}}", false)] + [InlineData("{\"PsuAgent\":{\"AppToken\":\"token\"}}", false)] + public void CompletenessRecognizesLegacyAndDeviceAuthentication(string agentConfig, bool expected) + { + string configPath = Path.GetTempFileName(); + try + { + File.WriteAllText(configPath, agentConfig); + + Assert.Equal(expected, CustomActions.PsuConfigIsComplete(configPath)); + } + finally + { + File.Delete(configPath); + } + } +} diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index f0af15ed0..843662e9a 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -1356,12 +1356,13 @@ public static bool TryReachPsuServer(string value, int timeoutMs, out string err } /// - /// Whether agent.json already holds a complete PsuAgent section (both a non-empty - /// ServerUrl and AppToken). Used to preserve an existing configuration on a + /// Whether agent.json already holds a complete PsuAgent section (a non-empty + /// ServerUrl and either a non-empty AppToken or a non-empty + /// DeviceEnrollment.RootThumbprint). Used to preserve an existing configuration on a /// silent upgrade that re-runs without passing PSU /// properties. Best-effort: any read/parse failure is treated as "not complete". /// - private static bool PsuConfigIsComplete(string configPath) + internal static bool PsuConfigIsComplete(string configPath) { try { @@ -1375,8 +1376,12 @@ private static bool PsuConfigIsComplete(string configPath) return false; } - return !string.IsNullOrWhiteSpace((string)psu["ServerUrl"]) - && !string.IsNullOrWhiteSpace((string)psu["AppToken"]); + bool hasServerUrl = !string.IsNullOrWhiteSpace((string)psu["ServerUrl"]); + bool hasAppToken = !string.IsNullOrWhiteSpace((string)psu["AppToken"]); + bool hasDeviceEnrollment = psu["DeviceEnrollment"] is JObject deviceEnrollment + && !string.IsNullOrWhiteSpace((string)deviceEnrollment["RootThumbprint"]); + + return hasServerUrl && (hasAppToken || hasDeviceEnrollment); } catch (Exception) { diff --git a/package/AgentWindowsManaged/README.md b/package/AgentWindowsManaged/README.md index 11907a444..feb5ff246 100644 --- a/package/AgentWindowsManaged/README.md +++ b/package/AgentWindowsManaged/README.md @@ -58,3 +58,73 @@ The version to use for the installer. Note that in Windows Installer, the produc The Desktop Agent uses `dotnet/DesktopAgent/Resources/AppIcon.ico` for its window and tray icon and `devolutions-agent-icon-shadow.png` for its About dialog. An MSI built with placeholder payloads is only for reviewing the wizard UI; do not install it. + +## Testing PSU device enrollment without installing a service + +Build this branch with Rust 1.90, MSVC C++ tools, the Windows SDK, and CMake available in the same Visual Studio developer shell: + +```powershell +cargo build --locked -p devolutions-agent +cargo test --locked -p testsuite --test integration_tests psu_device +``` + +Use a disposable PSU instance that implements the PSU-1337 enrollment protocol and returns the full leaf-first issuing chain in the signed bootstrap assertion. +Obtain its root certificate's SHA-256 DER fingerprint through a trusted, separate channel. +Do not discover the pin from the same untrusted enrollment connection. +The MSI wizard still configures legacy app-token authentication; device enrollment is an explicit `agent.json` setting. + +Set `DAGENT_CONFIG_PATH` to a new, access-restricted writable directory, not the installed agent's data directory. +Create `agent.json` there with the following configuration, replacing the endpoint, pin, and short-lived enrollment token: + +```json +{ + "Updater": { "Enabled": false }, + "Session": { "Enabled": false }, + "Pedm": { "Enabled": false }, + "PackageBroker": { "Enabled": false }, + "Tunnel": { "Enabled": false }, + "RemoteDesktop": { "Enabled": false }, + "PsuAgent": { + "Enabled": true, + "ServerUrl": "http://127.0.0.1:5006", + "DeviceEnrollment": { + "RootThumbprint": "<64-hex-digit-root-fingerprint>", + "EnrollmentToken": "" + }, + "PowerShell": { + "ExecutablePath": "C:\\Program Files\\PowerShell\\7\\pwsh.exe" + } + } +} +``` + +The token must have PSU's `AgentEnrollment` role and `AgentEnrollment` purpose, with a bounded use count and expiry. +Do not configure `PsuAgent.AppToken` in device mode. +Use HTTP only for a local HTTP/2 endpoint; a pinned proof does not encrypt traffic or prevent an active relay. +Remote HTTPS connections still require a valid, normally trusted TLS server certificate. + +```powershell +$env:DAGENT_CONFIG_PATH = 'C:\temp\psu-device-smoke' +.\target\debug\devolutions-agent.exe psu-device enroll +.\target\debug\devolutions-agent.exe psu-device status +.\target\debug\devolutions-agent.exe run +``` + +Check that PSU shows the server-issued device UUID and an online agent, then run a small PowerShell job. +Stop the foreground agent with Ctrl+C, remove `EnrollmentToken` from the test configuration, and restart `run`. +The device UUID and token use count must stay unchanged. +For a renewal smoke test, stop the agent, run `psu-device renew`, then restart `run` and confirm the new certificate is accepted. +No command above installs or starts a Windows service. + +State defaults to `psu-device\\identity.json` inside `DAGENT_CONFIG_PATH`. +`DeviceEnrollment.StateDirectory` overrides the base directory; keep it writable, private, and persistent. +Files use restricted permissions and atomic replacement, and an exclusive lock prevents concurrent agent and CLI operations on one identity. +The private key, issuing chain, pinned root, UUID, and expiry stay together. +The hardware identifier is an independent persisted installation UUID unless `DeviceEnrollment.HardwareId` supplies a stable provisioning identifier. +Do not copy one identity volume to multiple machines. + +Pending devices keep their issued credentials and retry connection with fresh proofs without consuming another enrollment token. +Automatic renewal starts seven days before expiry, or one fifth of the certificate lifetime for shorter-lived certificates; startup also renews an expired certificate before connecting. +Revocation, reset, corrupt state, a changed pin, or an ambiguous enrollment result stops device authentication and requires operator recovery. +There is no automatic re-enrollment, deletion, or Bearer fallback. +If an enrollment response was lost, reconcile the saved key and PSU device record before deliberately provisioning a new identity. diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index a40691073..8984b43b3 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -38,6 +38,7 @@ agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde base64 = "0.23" camino = "1" devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } +devolutions-agent = { path = "../devolutions-agent" } devolutions-gateway = { path = "../devolutions-gateway" } futures-util = "0.3" ipnetwork = "0.20" @@ -56,12 +57,18 @@ rstest = "0.25" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" rustls-pki-types = "1" +ring = "0.17" +prost-types = "0.13" serde_json = "1" sysevent.path = "../crates/sysevent" sysevent-codes.path = "../crates/sysevent-codes" tempfile = "3" test-utils.path = "../crates/test-utils" tokio-rustls = { version = "0.26", features = ["ring"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = { version = "0.12", features = ["transport"] } +time = "0.3" +x509-parser = "0.16" uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dev-dependencies] diff --git a/testsuite/tests/main.rs b/testsuite/tests/main.rs index 4b5861aa8..3ad35368b 100644 --- a/testsuite/tests/main.rs +++ b/testsuite/tests/main.rs @@ -6,6 +6,7 @@ mod agent_tunnel; mod cli; mod mcp_proxy; mod network_scanner; +mod psu_device; #[cfg(windows)] mod service_accounts; mod sysevent; diff --git a/testsuite/tests/psu_device.rs b/testsuite/tests/psu_device.rs new file mode 100644 index 000000000..60c7a38c6 --- /dev/null +++ b/testsuite/tests/psu_device.rs @@ -0,0 +1,765 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use devolutions_agent::config::{Conf, dto}; +use devolutions_agent::psu_agent::device::{ + DeviceIdentity, fingerprint, retryable, unix_now, validate_device_certificate, validate_trust_anchor, +}; +use devolutions_agent::psu_agent::protocol::agent_control_server::{AgentControl, AgentControlServer}; +use devolutions_agent::psu_agent::protocol::agent_enrollment_server::{AgentEnrollment, AgentEnrollmentServer}; +use devolutions_agent::psu_agent::protocol::{self, EnrollResponse, EnrollmentStatus}; +use devolutions_gateway_task::ShutdownHandle; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, DnType, ExtendedKeyUsagePurpose, + IsCa, KeyPair, KeyUsagePurpose, SanType, +}; +use ring::rand::SystemRandom; +use ring::signature::{self, EcdsaKeyPair}; +use tempfile::TempDir; +use time::OffsetDateTime; +use tokio::net::TcpListener; +use tokio::sync::{Mutex, mpsc}; +use tokio_stream::StreamExt as _; +use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream}; +use tonic::transport::Server; +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +struct Pki { + root: Certificate, + issuer: Certificate, + issuer_key: KeyPair, + server: Certificate, + server_key: KeyPair, +} + +impl Pki { + fn new() -> Self { + let mut root_params = CertificateParams::default(); + root_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(1)); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + root_params.distinguished_name.push(DnType::CommonName, "PSU test root"); + let root_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); + let root = root_params.self_signed(&root_key).unwrap(); + let mut issuer_params = CertificateParams::default(); + issuer_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + issuer_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + issuer_params + .distinguished_name + .push(DnType::CommonName, "PSU test issuer"); + let issuer_key = KeyPair::generate().unwrap(); + let issuer = issuer_params.signed_by(&issuer_key, &root, &root_key).unwrap(); + let mut server_params = CertificateParams::default(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + let server_key = KeyPair::generate().unwrap(); + let server = server_params.signed_by(&server_key, &issuer, &issuer_key).unwrap(); + Self { + root, + issuer, + issuer_key, + server, + server_key, + } + } + + fn trust(&self, nonce: &str) -> protocol::TrustAnchorResponse { + let header = serde_json::json!({ + "alg": "ES256", + "x5c": [STANDARD.encode(self.server.der()), STANDARD.encode(self.issuer.der()), STANDARD.encode(self.root.der())] + }); + let now = unix_now().unwrap(); + let claims = serde_json::json!({ + "aud": "psu-agent-enrollment", "nonce": nonce, "iat": now, "nbf": now - 5, "exp": now + 300 + }); + protocol::TrustAnchorResponse { + root_certificate: self.root.der().to_vec(), + root_thumbprint: fingerprint(self.root.der()), + server_assertion: sign(&self.server_key, &header, &claims), + } + } + + fn issue(&self, csr: &[u8], device_id: &str, seconds: i64) -> EnrollResponse { + let mut request = CertificateSigningRequestParams::from_der(&csr.into()).unwrap(); + request.params.distinguished_name = rcgen::DistinguishedName::new(); + request.params.distinguished_name.push(DnType::CommonName, device_id); + request.params.subject_alt_names = vec![SanType::URI(format!("urn:uuid:{device_id}").try_into().unwrap())]; + request.params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + request.params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + request.params.not_before = OffsetDateTime::now_utc() - time::Duration::minutes(5); + request.params.not_after = OffsetDateTime::now_utc() + time::Duration::seconds(seconds); + let expiry = request.params.not_after.unix_timestamp(); + let cert = request.signed_by(&self.issuer, &self.issuer_key).unwrap(); + EnrollResponse { + device_id: device_id.to_owned(), + certificate: cert.der().to_vec(), + chain: vec![self.issuer.der().to_vec(), self.root.der().to_vec()], + status: EnrollmentStatus::Approved as i32, + certificate_thumbprint: fingerprint(cert.der()), + not_after: Some(prost_types::Timestamp { + seconds: expiry, + nanos: 0, + }), + } + } +} + +fn sign(key: &KeyPair, header: &serde_json::Value, claims: &serde_json::Value) -> String { + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(header).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()) + ); + let key = EcdsaKeyPair::from_pkcs8( + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + &key.serialize_der(), + &SystemRandom::new(), + ) + .unwrap(); + let signature = key.sign(&SystemRandom::new(), input.as_bytes()).unwrap(); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.as_ref())) +} + +struct MockState { + pki: Pki, + device_id: String, + enrollees: AtomicUsize, + renewals: AtomicUsize, + connections: AtomicUsize, + pending: AtomicBool, + revoked: AtomicBool, + lose_enrollment_response: AtomicBool, + corrupt_renewal: AtomicBool, + first_lifetime: i64, + hardware_id: Mutex>, + jtis: Mutex>, +} + +impl MockState { + async fn verify_assertion(&self, auth: String) -> Result { + let token = auth + .strip_prefix("Device ") + .expect("device mode must never send Bearer"); + let parts = token.split('.').collect::>(); + assert_eq!(parts.len(), 3); + let header: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(header["alg"], "ES256"); + assert_eq!(header["x5c"].as_array().unwrap().len(), 1); + assert_eq!(claims["aud"], "psu-agent"); + assert_eq!(claims["sub"], self.device_id); + assert!(claims["exp"].as_i64().unwrap() - claims["nbf"].as_i64().unwrap() <= 300); + let now = unix_now().unwrap(); + assert!(claims["exp"].as_i64().unwrap() > now); + assert!(claims["iat"].as_i64().unwrap() <= now + 2); + assert!(claims["nbf"].as_i64().unwrap() <= now); + let jti = claims["jti"].as_str().unwrap().to_owned(); + assert!(Uuid::parse_str(&jti).is_ok()); + assert!(self.jtis.lock().await.insert(jti)); + let cert = STANDARD.decode(header["x5c"][0].as_str().unwrap()).unwrap(); + let (_, cert) = x509_parser::parse_x509_certificate(&cert).unwrap(); + let signature = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); + assert_eq!(signature.len(), 64, "ES256 signatures use JOSE r||s, not ASN.1"); + signature::UnparsedPublicKey::new( + &signature::ECDSA_P256_SHA256_FIXED, + cert.public_key().subject_public_key.data.as_ref(), + ) + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) + .unwrap(); + if self.revoked.load(Ordering::SeqCst) { + return Err(Status::permission_denied("The device is revoked.")); + } + if self.pending.load(Ordering::SeqCst) { + return Err(Status::permission_denied("The device is waiting for approval.")); + } + Ok(self.device_id.clone()) + } +} + +#[derive(Clone)] +struct Mock(Arc); + +#[tonic::async_trait] +impl AgentEnrollment for Mock { + async fn get_trust_anchor( + &self, + request: Request, + ) -> Result, Status> { + assert!( + !request.metadata().contains_key("authorization"), + "bootstrap must not disclose enrollment token" + ); + Ok(Response::new(self.0.pki.trust(&request.into_inner().nonce))) + } + + async fn enroll(&self, request: Request) -> Result, Status> { + assert_eq!( + request.metadata().get("authorization").unwrap(), + "Bearer enrollment-token" + ); + self.0.enrollees.fetch_add(1, Ordering::SeqCst); + let request = request.into_inner(); + assert_ne!(request.hardware_id, self.0.device_id); + assert!(!request.machine_name.is_empty()); + *self.0.hardware_id.lock().await = Some(request.hardware_id); + if self.0.lose_enrollment_response.load(Ordering::SeqCst) { + return Err(Status::unavailable("response was lost")); + } + let mut response = self.0.pki.issue(&request.csr, &self.0.device_id, self.0.first_lifetime); + if self.0.pending.load(Ordering::SeqCst) { + response.status = EnrollmentStatus::PendingApproval as i32; + } + Ok(Response::new(response)) + } + + async fn renew(&self, request: Request) -> Result, Status> { + self.0 + .verify_assertion( + request + .metadata() + .get("authorization") + .unwrap() + .to_str() + .unwrap() + .to_owned(), + ) + .await?; + self.0.renewals.fetch_add(1, Ordering::SeqCst); + let mut response = self + .0 + .pki + .issue(&request.into_inner().csr, &self.0.device_id, 90 * 86400); + if self.0.corrupt_renewal.load(Ordering::SeqCst) { + response.certificate_thumbprint = "00".repeat(32); + } + Ok(Response::new(response)) + } +} + +#[tonic::async_trait] +impl AgentControl for Mock { + type ConnectStream = ReceiverStream>; + + async fn connect( + &self, + request: Request>, + ) -> Result, Status> { + let authorization = request + .metadata() + .get("authorization") + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let device_id = if authorization == "Bearer legacy-agent-token" { + "legacy-agent-id".to_owned() + } else { + self.0.verify_assertion(authorization).await? + }; + let mut incoming = request.into_inner(); + let registration = incoming.message().await?.unwrap(); + assert_eq!(registration.agent_id, device_id); + match registration.payload.unwrap() { + protocol::agent_message::Payload::RegisterAgent(registration) => { + assert_eq!(registration.agent_id, device_id) + } + _ => panic!("registration must be the first message"), + } + self.0.connections.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = mpsc::channel(4); + tx.send(Ok(protocol::ServerMessage { + request_id: String::new(), + connection_id: "connection".to_owned(), + timestamp: None, + payload: Some(protocol::server_message::Payload::RegisterAccepted( + protocol::RegisterAccepted { + connection_id: "connection".to_owned(), + }, + )), + })) + .await + .unwrap(); + tokio::spawn(async move { + let _hold_sender = tx; + while incoming.next().await.is_some() {} + }); + Ok(Response::new(ReceiverStream::new(rx))) + } +} + +struct Harness { + state: Arc, + server_url: String, + shutdown: Option>, + server: tokio::task::JoinHandle>, + directory: TempDir, +} + +impl Harness { + async fn new(seconds: i64) -> Self { + let state = Arc::new(MockState { + pki: Pki::new(), + device_id: Uuid::new_v4().to_string(), + enrollees: AtomicUsize::new(0), + renewals: AtomicUsize::new(0), + connections: AtomicUsize::new(0), + pending: AtomicBool::new(false), + revoked: AtomicBool::new(false), + lose_enrollment_response: AtomicBool::new(false), + corrupt_renewal: AtomicBool::new(false), + first_lifetime: seconds, + hardware_id: Mutex::new(None), + jtis: Mutex::new(HashSet::new()), + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server_url = format!("http://{}", listener.local_addr().unwrap()); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let mock = Mock(Arc::clone(&state)); + let server = tokio::spawn( + Server::builder() + .add_service(AgentEnrollmentServer::new(mock.clone())) + .add_service(AgentControlServer::new(mock)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }), + ); + Self { + state, + server_url, + shutdown: Some(shutdown), + server, + directory: TempDir::new().unwrap(), + } + } + + fn config(&self) -> devolutions_agent::config::PsuConf { + let file: dto::ConfFile = serde_json::from_value(serde_json::json!({ + "PsuAgent": { "Enabled": true, "ServerUrl": self.server_url, "AgentId": "untrusted-agent-name", + "PowerShell": { "ExecutablePath": "missing-test-pwsh" }, + "DeviceEnrollment": { + "RootThumbprint": fingerprint(self.state.pki.root.der()), + "EnrollmentToken": "enrollment-token", + "StateDirectory": self.directory.path() + } + } + })) + .unwrap(); + Conf::from_conf_file(&file).unwrap().psu_agent.unwrap() + } + + async fn channel(&self) -> tonic::transport::Channel { + tonic::transport::Endpoint::new(self.server_url.clone()) + .unwrap() + .connect() + .await + .unwrap() + } + + async fn stop(mut self) { + self.shutdown.take().unwrap().send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), &mut self.server) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.server.abort(); + } +} + +#[test] +fn trust_anchor_rejects_pin_nonce_signature_and_missing_intermediate() { + let pki = Pki::new(); + let response = pki.trust("nonce"); + let pin = fingerprint(pki.root.der()); + validate_trust_anchor(&response, &pin, "nonce", unix_now().unwrap()).unwrap(); + assert!(validate_trust_anchor(&response, &"00".repeat(32), "nonce", unix_now().unwrap()).is_err()); + assert!(validate_trust_anchor(&response, &pin, "other-nonce", unix_now().unwrap()).is_err()); + let mut altered = response.clone(); + altered.server_assertion.push('A'); + assert!(validate_trust_anchor(&altered, &pin, "nonce", unix_now().unwrap()).is_err()); + let parts = response.server_assertion.split('.').collect::>(); + let mut header: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + header["x5c"] = serde_json::json!([STANDARD.encode(pki.server.der()), STANDARD.encode(pki.root.der())]); + let claims: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + altered.server_assertion = sign(&pki.server_key, &header, &claims); + assert!(validate_trust_anchor(&altered, &pin, "nonce", unix_now().unwrap()).is_err()); +} + +#[test] +fn trust_anchor_rejects_wrong_audience_expiry_and_algorithm() { + let pki = Pki::new(); + let mut response = pki.trust("nonce"); + let parts = response.server_assertion.split('.').collect::>(); + let header: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + for (field, value) in [ + ("aud", serde_json::json!("psu-agent")), + ("exp", serde_json::json!(unix_now().unwrap() - 1)), + ("exp", serde_json::json!(unix_now().unwrap() + 3600)), + ("iat", serde_json::json!(i64::MIN)), + ("nbf", serde_json::json!(i64::MIN)), + ] { + let mut bad = claims.clone(); + bad[field] = value; + response.server_assertion = sign(&pki.server_key, &header, &bad); + assert!(validate_trust_anchor(&response, &fingerprint(pki.root.der()), "nonce", unix_now().unwrap()).is_err()); + } + let mut bad = header; + bad["alg"] = serde_json::json!("HS256"); + response.server_assertion = sign(&pki.server_key, &bad, &claims); + assert!(validate_trust_anchor(&response, &fingerprint(pki.root.der()), "nonce", unix_now().unwrap()).is_err()); +} + +#[tokio::test] +async fn bootstrap_enroll_restart_expired_renew_and_device_connect() { + let harness = Harness::new(2).await; + let mut config = harness.config(); + let mut identity = DeviceIdentity::open(&config).unwrap(); + identity + .prepare(harness.channel().await, &config, "test-machine") + .await + .unwrap(); + assert_eq!(identity.device_id().unwrap(), harness.state.device_id); + let first_assertion = identity.authorization().unwrap(); + assert_ne!(first_assertion, identity.authorization().unwrap()); + assert!( + DeviceIdentity::open(&config).is_err(), + "identity must have an exclusive process lock" + ); + let state_path = identity.status()["StatePath"].as_str().unwrap().to_owned(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&state_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + assert!( + !std::fs::read_to_string(&state_path) + .unwrap() + .contains("enrollment-token") + ); + drop(identity); + config.device_enrollment.as_mut().unwrap().enrollment_token = None; + tokio::time::sleep(Duration::from_secs(3)).await; + let (shutdown, signal) = ShutdownHandle::new(); + let agent = tokio::spawn(devolutions_agent::psu_agent::run_psu_agent(config, signal)); + tokio::time::timeout(Duration::from_secs(10), async { + while harness.state.connections.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + shutdown.signal(); + tokio::time::timeout(Duration::from_secs(5), agent) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 1); + assert_eq!(harness.state.renewals.load(Ordering::SeqCst), 1); + harness.stop().await; +} + +#[tokio::test] +async fn pending_identity_retries_without_spending_enrollment_token() { + let harness = Harness::new(90 * 86400).await; + harness.state.pending.store(true, Ordering::SeqCst); + let config = harness.config(); + let (shutdown, signal) = ShutdownHandle::new(); + let agent = tokio::spawn(devolutions_agent::psu_agent::run_psu_agent(config.clone(), signal)); + tokio::time::timeout(Duration::from_secs(10), async { + while harness.state.enrollees.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + while harness.state.jtis.lock().await.len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + harness.state.pending.store(false, Ordering::SeqCst); + tokio::time::timeout(Duration::from_secs(10), async { + while harness.state.connections.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + shutdown.signal(); + agent.await.unwrap().unwrap(); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 1); + assert!(DeviceIdentity::open(&config).unwrap().device_id().is_ok()); + harness.stop().await; +} + +#[tokio::test] +async fn ambiguous_enrollment_is_not_resubmitted_after_restart() { + let harness = Harness::new(90 * 86400).await; + harness.state.lose_enrollment_response.store(true, Ordering::SeqCst); + let config = harness.config(); + let mut identity = DeviceIdentity::open(&config).unwrap(); + assert!( + identity + .prepare(harness.channel().await, &config, "test") + .await + .is_err() + ); + drop(identity); + let mut identity = DeviceIdentity::open(&config).unwrap(); + let error = identity + .prepare(harness.channel().await, &config, "test") + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome is unknown")); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 1); + drop(identity); + harness.stop().await; +} + +#[tokio::test] +async fn bad_bootstrap_pin_never_discloses_enrollment_token() { + let harness = Harness::new(90 * 86400).await; + let mut config = harness.config(); + config.device_enrollment.as_mut().unwrap().root_thumbprint = "00".repeat(32); + let mut identity = DeviceIdentity::open(&config).unwrap(); + assert!( + identity + .prepare(harness.channel().await, &config, "test") + .await + .is_err() + ); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 0); + drop(identity); + harness.stop().await; +} + +#[tokio::test] +async fn revoked_identity_stops_without_downgrade_or_reenrollment() { + let harness = Harness::new(90 * 86400).await; + let config = harness.config(); + let mut identity = DeviceIdentity::open(&config).unwrap(); + identity + .prepare(harness.channel().await, &config, "test") + .await + .unwrap(); + drop(identity); + harness.state.revoked.store(true, Ordering::SeqCst); + let (_shutdown, signal) = ShutdownHandle::new(); + let result = tokio::time::timeout( + Duration::from_secs(5), + devolutions_agent::psu_agent::run_psu_agent(config, signal), + ) + .await + .unwrap(); + assert!(result.unwrap_err().to_string().contains("operator action")); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 1); + harness.stop().await; +} + +#[test] +fn retry_policy_distinguishes_pending_from_revocation_and_reset() { + assert!(retryable(&anyhow::Error::new(Status::permission_denied( + "The device is waiting for approval." + )))); + assert!(!retryable(&anyhow::Error::new(Status::permission_denied( + "The device is revoked." + )))); + assert!(!retryable(&anyhow::Error::new(Status::unauthenticated( + "The certificate does not match the one recorded for this device." + )))); +} + +#[tokio::test] +async fn failed_renewal_preserves_previous_usable_identity() { + let harness = Harness::new(90 * 86400).await; + let config = harness.config(); + let mut identity = DeviceIdentity::open(&config).unwrap(); + identity + .prepare(harness.channel().await, &config, "test") + .await + .unwrap(); + let path = identity.status()["StatePath"].as_str().unwrap().to_owned(); + let previous = std::fs::read(&path).unwrap(); + drop(identity); + harness.state.corrupt_renewal.store(true, Ordering::SeqCst); + assert!(devolutions_agent::psu_agent::device::renew(&config).await.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), previous); + let identity = DeviceIdentity::open(&config).unwrap(); + assert_eq!(identity.device_id().unwrap(), harness.state.device_id); + drop(identity); + harness.stop().await; +} + +#[test] +fn device_certificate_rejects_wrong_identity_key_chain_and_eku() { + let pki = Pki::new(); + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::default(); + params.distinguished_name.push(DnType::CommonName, "test-csr"); + let csr = params.serialize_request(&key).unwrap(); + let id = Uuid::new_v4().to_string(); + let response = pki.issue(csr.der(), &id, 3600); + let public_key = signature::KeyPair::public_key( + &EcdsaKeyPair::from_pkcs8( + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + &key.serialize_der(), + &SystemRandom::new(), + ) + .unwrap(), + ) + .as_ref() + .to_vec(); + let pin = fingerprint(pki.root.der()); + validate_device_certificate( + &response.certificate, + &response.chain, + pki.root.der(), + &pin, + &public_key, + &id, + unix_now().unwrap(), + ) + .unwrap(); + assert!( + validate_device_certificate( + &response.certificate, + &response.chain, + pki.root.der(), + &pin, + &public_key, + &Uuid::new_v4().to_string(), + unix_now().unwrap() + ) + .is_err() + ); + assert!( + validate_device_certificate( + &response.certificate, + &response.chain, + pki.root.der(), + &pin, + &[0; 65], + &id, + unix_now().unwrap() + ) + .is_err() + ); + assert!( + validate_device_certificate( + &response.certificate, + &[pki.root.der().to_vec()], + pki.root.der(), + &pin, + &public_key, + &id, + unix_now().unwrap() + ) + .is_err() + ); + assert!( + validate_device_certificate( + pki.server.der(), + &response.chain, + pki.root.der(), + &pin, + &public_key, + &id, + unix_now().unwrap() + ) + .is_err() + ); + assert!( + validate_device_certificate( + &response.certificate, + &response.chain, + pki.root.der(), + &pin, + &public_key, + &id, + response.not_after.unwrap().seconds + 1 + ) + .is_err() + ); +} + +#[tokio::test] +async fn corrupt_or_rebound_state_is_rejected_without_enrollment() { + let harness = Harness::new(90 * 86400).await; + let config = harness.config(); + let mut identity = DeviceIdentity::open(&config).unwrap(); + identity + .prepare(harness.channel().await, &config, "test") + .await + .unwrap(); + let path = identity.status()["StatePath"].as_str().unwrap().to_owned(); + drop(identity); + let state: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + for (field, value) in [ + ("key_pkcs8", serde_json::json!("corrupt")), + ("device_id", serde_json::json!(Uuid::new_v4())), + ("root_thumbprint", serde_json::json!("00".repeat(32))), + ("server_url", serde_json::json!("http://other-server:5006")), + ] { + let mut bad = state.clone(); + bad[field] = value; + std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap(); + assert!(DeviceIdentity::open(&config).is_err()); + } + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 1); + harness.stop().await; +} + +#[test] +fn device_config_is_explicit_and_never_falls_back_to_app_token() { + for value in [ + serde_json::json!({"Enabled": true, "ServerUrl": "http://localhost:5006", "AppToken": "legacy"}), + serde_json::json!({"Enabled": true, "ServerUrl": "http://localhost:5006", + "DeviceEnrollment": {"RootThumbprint": "00".repeat(32)}}), + ] { + let conf: dto::ConfFile = serde_json::from_value(serde_json::json!({"PsuAgent": value})).unwrap(); + assert!(Conf::from_conf_file(&conf).is_ok()); + } + + let invalid: dto::ConfFile = serde_json::from_value(serde_json::json!({"PsuAgent": { + "Enabled": true, "ServerUrl": "http://localhost:5006", "AppToken": "legacy", + "DeviceEnrollment": {"RootThumbprint": "00".repeat(32), "EnrollmentToken": "never-log-this"} + }})) + .unwrap(); + assert!(Conf::from_conf_file(&invalid).is_err()); + assert!(!format!("{invalid:?}").contains("never-log-this")); +} + +#[tokio::test] +async fn legacy_runtime_connects_with_bearer_without_enrollment() { + let harness = Harness::new(90 * 86400).await; + let mut config = harness.config(); + config.device_enrollment = None; + config.app_token = "legacy-agent-token".to_owned(); + config.agent_id = Some("legacy-agent-id".to_owned()); + let (shutdown, signal) = ShutdownHandle::new(); + let agent = tokio::spawn(devolutions_agent::psu_agent::run_psu_agent(config, signal)); + tokio::time::timeout(Duration::from_secs(5), async { + while harness.state.connections.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + shutdown.signal(); + agent.await.unwrap().unwrap(); + assert_eq!(harness.state.enrollees.load(Ordering::SeqCst), 0); + assert_eq!(harness.state.renewals.load(Ordering::SeqCst), 0); + harness.stop().await; +}