Skip to content

Commit 87be5cd

Browse files
feat(agent): complete and improve the enrollment flow for Devolutions Agent (#1773)
1 parent 8ceb2ad commit 87be5cd

11 files changed

Lines changed: 148 additions & 219 deletions

File tree

crates/agent-tunnel/src/enrollment_store.rs

Lines changed: 0 additions & 138 deletions
This file was deleted.

crates/agent-tunnel/src/lib.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@
77
extern crate tracing;
88

99
pub mod cert;
10-
pub mod enrollment_store;
1110
pub mod listener;
1211
pub mod registry;
1312
pub mod routing;
1413
pub mod stream;
1514

16-
pub use enrollment_store::EnrollmentTokenStore;
1715
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1816
pub use registry::AgentRegistry;
1917
pub use stream::TunnelStream;

crates/agent-tunnel/src/listener.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use tokio::sync::RwLock;
1616
use uuid::Uuid;
1717

1818
use super::cert::CaManager;
19-
use super::enrollment_store::EnrollmentTokenStore;
2019
use super::registry::{AgentPeer, AgentRegistry};
2120
use super::stream::TunnelStream;
2221

@@ -33,7 +32,6 @@ pub struct AgentTunnelHandle {
3332
/// Map of agent_id → live Quinn connection, used for opening new streams.
3433
agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>>,
3534
ca_manager: Arc<CaManager>,
36-
enrollment_token_store: Arc<EnrollmentTokenStore>,
3735
}
3836

3937
impl AgentTunnelHandle {
@@ -45,10 +43,6 @@ impl AgentTunnelHandle {
4543
&self.ca_manager
4644
}
4745

48-
pub fn enrollment_token_store(&self) -> &EnrollmentTokenStore {
49-
&self.enrollment_token_store
50-
}
51-
5246
/// Open a proxy stream through a connected agent.
5347
// TODO: Emit TrafficEvent for connections routed through the agent tunnel.
5448
pub async fn connect_via_agent(
@@ -153,13 +147,11 @@ impl AgentTunnelListener {
153147

154148
let registry = Arc::new(AgentRegistry::new());
155149
let agent_connections: Arc<RwLock<HashMap<Uuid, quinn::Connection>>> = Arc::new(RwLock::new(HashMap::new()));
156-
let enrollment_token_store = Arc::new(EnrollmentTokenStore::new());
157150

158151
let handle = AgentTunnelHandle {
159152
registry: Arc::clone(&registry),
160153
agent_connections: Arc::clone(&agent_connections),
161-
ca_manager: Arc::clone(&ca_manager),
162-
enrollment_token_store,
154+
ca_manager,
163155
};
164156

165157
let listener = Self {

devolutions-gateway/src/api/tunnel.rs

Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use axum::extract::{Path, State};
22
use axum::http::HeaderMap;
33
use axum::{Json, Router};
4+
use serde::{Deserialize, Serialize};
45
use uuid::Uuid;
56

67
use crate::DgwState;
@@ -11,7 +12,7 @@ use crate::http::HttpError;
1112
///
1213
/// Returns `true` if the token is a well-formed JWT whose signature verifies
1314
/// against `provisioner_key`, whose `exp` has not passed, and whose `scope`
14-
/// is `TunnelEnroll` (or `Wildcard`). Returns `false` for any failure.
15+
/// is `AgentEnroll` (or `Wildcard`). Returns `false` for any failure.
1516
///
1617
/// The enrollment JWT carries extra claims (`jet_gw_url`, `jet_agent_name`)
1718
/// that the *agent* reads locally from its own copy of the token — the Gateway
@@ -39,25 +40,10 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey)
3940

4041
matches!(
4142
validated.state.claims.scope,
42-
AccessScope::TunnelEnroll | AccessScope::Wildcard
43+
AccessScope::AgentEnroll | AccessScope::Wildcard
4344
)
4445
}
4546

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

9783
/// Enroll a new agent.
9884
///
99-
/// Requires a Bearer token matching the configured enrollment secret
100-
/// or a valid one-time enrollment token from the store.
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.
10187
///
10288
/// The agent generates its own key pair and sends a CSR. The gateway signs it
10389
/// and returns the certificate. The private key never leaves the agent.
@@ -108,13 +94,15 @@ async fn enroll_agent(
10894
..
10995
}): State<DgwState>,
11096
headers: HeaderMap,
111-
Json(req): Json<EnrollRequest>,
97+
Json(EnrollRequest {
98+
agent_id,
99+
agent_name,
100+
csr_pem,
101+
agent_hostname,
102+
}): Json<EnrollRequest>,
112103
) -> Result<Json<EnrollResponse>, HttpError> {
113104
// Validate agent name: 1-255 printable ASCII characters.
114-
if req.agent_name.is_empty()
115-
|| 255 < req.agent_name.len()
116-
|| req.agent_name.bytes().any(|b| !(0x20..=0x7E).contains(&b))
117-
{
105+
if agent_name.is_empty() || 255 < agent_name.len() || agent_name.bytes().any(|b| !(0x20..=0x7E).contains(&b)) {
118106
return Err(HttpError::bad_request().msg("agent name must be 1-255 printable ASCII characters"));
119107
}
120108

@@ -134,30 +122,10 @@ async fn enroll_agent(
134122
.as_ref()
135123
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
136124

137-
// Token validation order:
138-
// 1. JWT signed by the configured provisioner key (scope == TunnelEnroll)
139-
// 2. One-time enrollment token from the in-memory store
140-
// 3. Static enrollment secret from configuration (constant-time comparison)
141-
let jwt_valid = validate_enrollment_jwt(provided_token, &conf.provisioner_public_key);
142-
143-
if !jwt_valid {
144-
let token_valid = handle.enrollment_token_store().redeem(provided_token).await;
145-
146-
if !token_valid {
147-
let enrollment_secret = conf
148-
.agent_tunnel
149-
.enrollment_secret
150-
.as_deref()
151-
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
152-
153-
if !timing_safe_eq(provided_token.as_bytes(), enrollment_secret.as_bytes()) {
154-
return Err(HttpError::forbidden().msg("invalid enrollment token"));
155-
}
156-
}
125+
if !validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) {
126+
return Err(HttpError::forbidden().msg("invalid enrollment token"));
157127
}
158128

159-
let agent_id = req.agent_id;
160-
161129
// Reject duplicate agent IDs to prevent identity shadowing.
162130
if handle.registry().get(&agent_id).await.is_some() {
163131
return Err(
@@ -167,7 +135,7 @@ async fn enroll_agent(
167135

168136
let signed = handle
169137
.ca_manager()
170-
.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())
171139
.map_err(HttpError::bad_request().with_msg("invalid CSR").err())?;
172140

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

180148
info!(
181149
%agent_id,
182-
agent_name = %req.agent_name,
150+
agent_name = %agent_name,
183151
"Agent enrolled successfully",
184152
);
185153

@@ -282,7 +250,7 @@ mod tests {
282250
let (priv_key, pub_key) = keypair();
283251
let token = sign(
284252
json!({
285-
"scope": "gateway.tunnel.enroll",
253+
"scope": "gateway.agent.enroll",
286254
"nbf": now_ts() - 60,
287255
"exp": now_ts() + 3600,
288256
"jti": Uuid::new_v4(),
@@ -334,7 +302,7 @@ mod tests {
334302
let (priv_key, pub_key) = keypair();
335303
let token = sign(
336304
json!({
337-
"scope": "gateway.tunnel.enroll",
305+
"scope": "gateway.agent.enroll",
338306
"nbf": now_ts() - 7200,
339307
"exp": now_ts() - 3600,
340308
"jti": Uuid::new_v4(),
@@ -352,7 +320,7 @@ mod tests {
352320
let (_, gateway_pub) = keypair();
353321
let token = sign(
354322
json!({
355-
"scope": "gateway.tunnel.enroll",
323+
"scope": "gateway.agent.enroll",
356324
"nbf": now_ts() - 60,
357325
"exp": now_ts() + 3600,
358326
"jti": Uuid::new_v4(),
@@ -369,7 +337,7 @@ mod tests {
369337
let (priv_key, pub_key) = keypair();
370338
let token = sign(
371339
json!({
372-
"scope": "gateway.tunnel.enroll",
340+
"scope": "gateway.agent.enroll",
373341
"nbf": now_ts() - 60,
374342
"exp": now_ts() + 3600,
375343
"jti": Uuid::new_v4(),

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
}

0 commit comments

Comments
 (0)