Skip to content

Commit 9ca0eb1

Browse files
fix(agent-tunnel): address Benoit review on PR #1773
- Drop static enrollment_secret fallback: the product never shipped without DVLS, so the no-DVLS bootstrap path is not a supported scenario. Removes the conf field, the timing_safe_eq helper, and the secret-compare branch. - Reword enroll endpoint doc to treat DVLS as one example of a provisioner (Hub / PEM service can equally sign the JWT). - Strip back-compat scopes (DiagnosticsRead / ConfigWrite) from the agent management read extractor, and ConfigWrite from the write extractor; the endpoints are new and have no callers predating the dedicated agent scopes. A follow-up PR will introduce AgentDelete and switch the write extractor over.
1 parent ae56597 commit 9ca0eb1

3 files changed

Lines changed: 23 additions & 79 deletions

File tree

devolutions-gateway/src/api/tunnel.rs

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,6 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey)
4444
)
4545
}
4646

47-
/// Timing-safe byte comparison for secret values.
48-
///
49-
/// Both inputs are first hashed with SHA-256 to produce fixed 32-byte digests;
50-
/// the digest comparison then runs in constant time (fixed-length XOR fold).
51-
/// Hashing normalizes length so a leaked hash duration cannot reveal the
52-
/// secret's length; and the constant-time fold prevents leaking which byte
53-
/// differed. The function is *not* constant-time over input length, which is
54-
/// why it is named after its intent (timing-safe) rather than its mechanism.
55-
fn timing_safe_eq(a: &[u8], b: &[u8]) -> bool {
56-
use sha2::{Digest, Sha256};
57-
let da = Sha256::digest(a);
58-
let db = Sha256::digest(b);
59-
da.iter().zip(db.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
60-
}
61-
6247
#[derive(Deserialize)]
6348
pub struct EnrollRequest {
6449
/// Agent-generated UUID (the agent owns its identity).
@@ -97,12 +82,8 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
9782

9883
/// Enroll a new agent.
9984
///
100-
/// Requires a Bearer token that is either:
101-
/// - a JWT signed by the configured provisioner key with `AgentEnroll` /
102-
/// `Wildcard` scope (issued by DVLS — the only authority for agent
103-
/// enrollment tokens), or
104-
/// - the static `enrollment_secret` from the gateway configuration (admin
105-
/// bootstrap fallback for environments without DVLS).
85+
/// Requires a Bearer token: a JWT signed by the configured provisioner key
86+
/// (e.g. DVLS, Hub, or any PEM service) with `AgentEnroll` or `Wildcard` scope.
10687
///
10788
/// The agent generates its own key pair and sends a CSR. The gateway signs it
10889
/// and returns the certificate. The private key never leaves the agent.
@@ -113,13 +94,15 @@ async fn enroll_agent(
11394
..
11495
}): State<DgwState>,
11596
headers: HeaderMap,
116-
Json(req): Json<EnrollRequest>,
97+
Json(EnrollRequest {
98+
agent_id,
99+
agent_name,
100+
csr_pem,
101+
agent_hostname,
102+
}): Json<EnrollRequest>,
117103
) -> Result<Json<EnrollResponse>, HttpError> {
118104
// Validate agent name: 1-255 printable ASCII characters.
119-
if req.agent_name.is_empty()
120-
|| 255 < req.agent_name.len()
121-
|| req.agent_name.bytes().any(|b| !(0x20..=0x7E).contains(&b))
122-
{
105+
if agent_name.is_empty() || 255 < agent_name.len() || agent_name.bytes().any(|b| !(0x20..=0x7E).contains(&b)) {
123106
return Err(HttpError::bad_request().msg("agent name must be 1-255 printable ASCII characters"));
124107
}
125108

@@ -139,30 +122,10 @@ async fn enroll_agent(
139122
.as_ref()
140123
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
141124

142-
// Token validation order:
143-
// 1. JWT signed by the configured provisioner key (scope == AgentEnroll)
144-
// 2. Static enrollment secret from configuration (constant-time comparison)
145-
let jwt_valid = validate_enrollment_jwt(provided_token, &conf.provisioner_public_key);
146-
147-
if !jwt_valid {
148-
// The JWT failed to validate against the provisioner key. The static
149-
// `enrollment_secret` is only a fallback for environments without DVLS;
150-
// when it is not configured, the request is simply unauthenticated, not
151-
// a server-config issue — so 403, not 404 (404 is reserved for the agent
152-
// tunnel feature itself being disabled).
153-
let enrollment_secret = conf
154-
.agent_tunnel
155-
.enrollment_secret
156-
.as_deref()
157-
.ok_or_else(|| HttpError::forbidden().msg("invalid enrollment token"))?;
158-
159-
if !timing_safe_eq(provided_token.as_bytes(), enrollment_secret.as_bytes()) {
160-
return Err(HttpError::forbidden().msg("invalid enrollment token"));
161-
}
125+
if !validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) {
126+
return Err(HttpError::forbidden().msg("invalid enrollment token"));
162127
}
163128

164-
let agent_id = req.agent_id;
165-
166129
// Reject duplicate agent IDs to prevent identity shadowing.
167130
if handle.registry().get(&agent_id).await.is_some() {
168131
return Err(
@@ -172,7 +135,7 @@ async fn enroll_agent(
172135

173136
let signed = handle
174137
.ca_manager()
175-
.sign_agent_csr(agent_id, &req.agent_name, &req.csr_pem, req.agent_hostname.as_deref())
138+
.sign_agent_csr(agent_id, &agent_name, &csr_pem, agent_hostname.as_deref())
176139
.map_err(HttpError::bad_request().with_msg("invalid CSR").err())?;
177140

178141
let quic_endpoint = format!("{}:{}", conf.hostname, conf.agent_tunnel.listen_port);
@@ -184,7 +147,7 @@ async fn enroll_agent(
184147

185148
info!(
186149
%agent_id,
187-
agent_name = %req.agent_name,
150+
agent_name = %agent_name,
188151
"Agent enrolled successfully",
189152
);
190153

devolutions-gateway/src/config.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,10 +1941,6 @@ pub mod dto {
19411941
/// UDP port for the QUIC listener (default: 4433)
19421942
#[serde(default = "AgentTunnelConf::default_listen_port")]
19431943
pub listen_port: u16,
1944-
/// Shared secret for agent enrollment.
1945-
/// If set, agents can enroll by providing this secret as a Bearer token.
1946-
#[serde(default, skip_serializing_if = "Option::is_none")]
1947-
pub enrollment_secret: Option<String>,
19481944
}
19491945

19501946
impl AgentTunnelConf {
@@ -1958,7 +1954,6 @@ pub mod dto {
19581954
Self {
19591955
enabled: false,
19601956
listen_port: Self::default_listen_port(),
1961-
enrollment_secret: None,
19621957
}
19631958
}
19641959
}

devolutions-gateway/src/extract.rs

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -408,9 +408,7 @@ where
408408

409409
/// Grants read access to agent management endpoints.
410410
///
411-
/// Accepts a scope token with `AgentRead`, `DiagnosticsRead`, `ConfigWrite`, or
412-
/// `Wildcard` scope. `DiagnosticsRead` / `ConfigWrite` are kept for
413-
/// back-compat with callers that predate the dedicated `AgentRead` scope.
411+
/// Accepts a scope token with `AgentRead` or `Wildcard` scope.
414412
#[derive(Clone, Copy)]
415413
pub struct AgentManagementReadAccess;
416414

@@ -426,28 +424,21 @@ where
426424
.map_err(HttpError::internal().err())?
427425
.0;
428426

429-
// Accepted scopes:
430-
// - AgentRead: the canonical scope for this endpoint (DVLS uses this).
431-
// - DiagnosticsRead / ConfigWrite: back-compat for older callers that predate
432-
// the dedicated agent scopes; safe because both imply broader privileges.
433427
match claims {
434428
AccessTokenClaims::Scope(scope) => match scope.scope {
435-
AccessScope::Wildcard
436-
| AccessScope::AgentRead
437-
| AccessScope::DiagnosticsRead
438-
| AccessScope::ConfigWrite => Ok(Self),
439-
_ => Err(HttpError::forbidden().msg(
440-
"invalid scope for agent management read (require one of: gateway.agent.read, gateway.diagnostics.read, gateway.config.write, *)",
441-
)),
429+
AccessScope::Wildcard | AccessScope::AgentRead => Ok(Self),
430+
_ => Err(HttpError::forbidden()
431+
.msg("invalid scope for agent management read (require one of: gateway.agent.read, *)")),
442432
},
443433
_ => Err(HttpError::forbidden().msg("scope token required for agent management read")),
444434
}
445435
}
446436
}
447437

448-
/// Grants write access to agent management endpoints (e.g. enrollment, delete).
438+
/// Grants write access to agent management endpoints.
449439
///
450-
/// Accepts scope tokens with `AgentEnroll`, `ConfigWrite`, or `Wildcard` scope.
440+
/// Accepts scope tokens with `AgentEnroll` or `Wildcard` scope. A dedicated
441+
/// `AgentDelete` scope will replace `AgentEnroll` here in a follow-up PR.
451442
#[derive(Clone, Copy)]
452443
pub struct AgentManagementWriteAccess;
453444

@@ -463,16 +454,11 @@ where
463454
.map_err(HttpError::internal().err())?
464455
.0;
465456

466-
// Accepted scopes:
467-
// - AgentEnroll: the canonical scope for this endpoint (DVLS uses this).
468-
// - ConfigWrite: back-compat for older callers that predate the
469-
// dedicated agent scope.
470457
match claims {
471458
AccessTokenClaims::Scope(scope) => match scope.scope {
472-
AccessScope::Wildcard | AccessScope::AgentEnroll | AccessScope::ConfigWrite => Ok(Self),
473-
_ => Err(HttpError::forbidden().msg(
474-
"invalid scope for agent management write (require one of: gateway.agent.enroll, gateway.config.write, *)",
475-
)),
459+
AccessScope::Wildcard | AccessScope::AgentEnroll => Ok(Self),
460+
_ => Err(HttpError::forbidden()
461+
.msg("invalid scope for agent management write (require one of: gateway.agent.enroll, *)")),
476462
},
477463
_ => Err(HttpError::forbidden().msg("scope token required for agent management write")),
478464
}

0 commit comments

Comments
 (0)