Private
Public Access
1
0

fix(security): harden enrollment PKI bundle retrieval (#12)

- Add single-retrieval semantics: approved PKI bundles are atomically
  removed from the in-memory cache on first retrieval via DashMap::remove(),
  preventing concurrent requests from obtaining the private key
- Add TTL expiry: ApprovedEntry wraps PkiBundle with approved_at and ttl
  fields; bundles expire after ENROLLMENT_BUNDLE_TTL_SECS (600s / 10 min)
- Replace brute-force clear() purge with TTL-based retain() in background
  task, running every 60s instead of every 600s
- Audit tracing calls: confirm no raw polling token is logged; add security
  comment documenting this policy
- Document CSR-based enrollment as future enhancement in both enrollment.rs
  and SECURITY.md, explaining why server-generated keys are used currently
This commit is contained in:
Draco-Lunaris-Echo
2026-06-02 15:16:44 -05:00
committed by GitHub
parent 59df98504c
commit 8873b2c70c
4 changed files with 112 additions and 16 deletions

View File

@ -41,6 +41,28 @@ This project is a security tool — we hold ourselves to a high standard:
- **CI enforcement**: All PRs require passing CI checks (fmt, clippy, test, audit, build) - **CI enforcement**: All PRs require passing CI checks (fmt, clippy, test, audit, build)
- **Dependency auditing**: `cargo audit` runs in CI to catch known vulnerabilities - **Dependency auditing**: `cargo audit` runs in CI to catch known vulnerabilities
## Enrollment PKI Design Decisions
### Server-Generated Keys vs CSR-Based Enrollment
Currently, the server generates the agent's private key during enrollment approval and
transmits it over the mTLS-secured polling endpoint. This approach was chosen for
initial implementation simplicity — the agent polls a single endpoint and receives a
complete PKI bundle without an extra round-trip.
**Mitigations in place:**
- The PKI bundle is stored in an in-memory cache with single-retrieval semantics —
it can only be fetched once and is atomically removed on retrieval.
- A 10-minute TTL ensures the bundle expires even if never retrieved.
- The raw polling token is never logged; only its SHA-256 hash is stored.
**Future direction:** A CSR-based enrollment flow should replace server-generated keys.
Under that model, the agent generates its own key pair locally and submits a Certificate
Signing Request, eliminating the need for the server to ever hold or transmit the agent's
private key. This significantly reduces the attack surface.
See: [Issue #9](https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9)
## Credit ## Credit
Contributors who responsibly report vulnerabilities will be credited in the corresponding GitHub Security Advisory. Contributors who responsibly report vulnerabilities will be credited in the corresponding GitHub Security Advisory.

38
crates/pm-core/src/models.rs Executable file → Normal file
View File

@ -180,6 +180,44 @@ pub struct PkiBundle {
pub server_key: String, pub server_key: String,
} }
/// Time-to-live for approved enrollment PKI bundles (10 minutes).
///
/// After approval, the agent has this duration to retrieve its PKI bundle
/// via the polling endpoint. Once retrieved (single-use) or expired,
/// the bundle is permanently removed from the in-memory cache.
///
/// This TTL balances security (limiting private key exposure in memory)
/// against reliability (giving agents enough time to poll after approval).
pub const ENROLLMENT_BUNDLE_TTL_SECS: u32 = 600; // 10 minutes
/// An approved enrollment PKI bundle awaiting single-use retrieval.
///
/// Stored in the in-memory cache between admin approval and agent pickup.
/// The entry is removed atomically on first retrieval and expires after
/// the configured TTL, whichever comes first.
#[derive(Debug, Clone)]
pub struct ApprovedEntry {
pub pki: PkiBundle,
pub approved_at: chrono::DateTime<Utc>,
pub ttl: chrono::Duration,
}
impl ApprovedEntry {
/// Create a new entry with the current timestamp and default TTL.
pub fn new(pki: PkiBundle) -> Self {
Self {
pki,
approved_at: Utc::now(),
ttl: chrono::Duration::seconds(ENROLLMENT_BUNDLE_TTL_SECS as i64),
}
}
/// Returns true if this entry has exceeded its TTL.
pub fn is_expired(&self) -> bool {
Utc::now() > self.approved_at + self.ttl
}
}
// ============================================================ // ============================================================
// Health Checks // Health Checks
// ============================================================ // ============================================================

View File

@ -10,7 +10,7 @@ use pm_auth::{
rbac::{require_auth, AuthConfig}, rbac::{require_auth, AuthConfig},
}; };
use pm_core::{ use pm_core::{
config::AppConfig, db, logging, models::PkiBundle, request_id::request_id_middleware, config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware,
}; };
use routes::sso::{OidcCache, SsoSession}; use routes::sso::{OidcCache, SsoSession};
use routes::ws::WsTicket; use routes::ws::WsTicket;
@ -41,7 +41,10 @@ pub struct AppState {
/// Internal certificate authority for mTLS client cert issuance. /// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>, pub ca: Arc<pm_ca::CertAuthority>,
/// Short-lived cache for approved enrollment PKI bundles. /// Short-lived cache for approved enrollment PKI bundles.
pub approved_enrollments: Arc<DashMap<String, PkiBundle>>, ///
/// Entries are single-use (removed on retrieval) and expire after
/// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS).
pub approved_enrollments: Arc<DashMap<String, ApprovedEntry>>,
} }
#[tokio::main] #[tokio::main]
@ -101,7 +104,7 @@ async fn main() -> anyhow::Result<()> {
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new()); let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new()); let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default())); let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
let approved_enrollments: Arc<DashMap<String, PkiBundle>> = Arc::new(DashMap::new()); let approved_enrollments: Arc<DashMap<String, ApprovedEntry>> = Arc::new(DashMap::new());
// Background task: purge expired WS tickets every 30 seconds. // Background task: purge expired WS tickets every 30 seconds.
{ {
@ -140,14 +143,21 @@ async fn main() -> anyhow::Result<()> {
}); });
} }
// Background task: purge approved enrollment PKI bundles every 10 minutes. // Background task: purge expired approved enrollment PKI bundles.
// Entries are also removed on first retrieval (single-use), so this
// task only cleans up bundles that were never picked up by the agent.
{ {
let approved = approved_enrollments.clone(); let approved = approved_enrollments.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(600)); let mut interval = tokio::time::interval(Duration::from_secs(60));
loop { loop {
interval.tick().await; interval.tick().await;
approved.clear(); let before = approved.len();
approved.retain(|_, entry| !entry.is_expired());
let removed = before.saturating_sub(approved.len());
if removed > 0 {
tracing::debug!(removed, "Purged expired enrollment PKI bundles");
}
} }
}); });
} }

View File

@ -11,7 +11,8 @@ use pm_auth::AuthUser;
use pm_core::{ use pm_core::{
db, db,
models::{ models::{
CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host, PkiBundle, ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
PkiBundle,
}, },
}; };
use rand::{distributions::Alphanumeric, Rng}; use rand::{distributions::Alphanumeric, Rng};
@ -76,7 +77,10 @@ async fn enroll_status(
State(state): State<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
// Hash the provided token to match DB // Hash the provided token to match DB.
// Security note: the raw polling token is intentionally never logged.
// Only the SHA-256 hash is stored and compared; all tracing calls in
// this module log error contexts only, never the token itself.
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
@ -98,11 +102,17 @@ async fn enroll_status(
} }
// 2. If not in pending, check if it was recently approved. // 2. If not in pending, check if it was recently approved.
if let Some(pki) = state.approved_enrollments.get(&token_hash) { // Single-retrieval: remove() atomically consumes the entry, ensuring
// the private key can only be fetched once regardless of concurrent requests.
if let Some((_, entry)) = state.approved_enrollments.remove(&token_hash) {
if entry.is_expired() {
// Bundle TTL expired — treat as not found. Entry is already removed.
return Ok(Json(EnrollmentStatusResponse::NotFound));
}
return Ok(Json(EnrollmentStatusResponse::Approved { return Ok(Json(EnrollmentStatusResponse::Approved {
ca_crt: pki.ca_crt.clone(), ca_crt: entry.pki.ca_crt.clone(),
server_crt: pki.server_crt.clone(), server_crt: entry.pki.server_crt.clone(),
server_key: pki.server_key.clone(), server_key: entry.pki.server_key.clone(),
})); }));
} }
@ -277,15 +287,31 @@ async fn approve_enrollment(
) )
})?; })?;
// Store PKI bundle in cache for client retrieval // Store PKI bundle in cache for single-use client retrieval.
//
// Design decision — server-generated keys vs CSR-based enrollment:
// Currently the server generates the agent's private key and transmits it
// over the (already mTLS-secured) polling endpoint. This approach was chosen
// for initial implementation simplicity: the agent only needs to poll one
// endpoint and receives a complete PKI bundle without an extra round-trip.
//
// A future enhancement should adopt CSR-based enrollment where the agent
// generates its own key pair locally and submits a Certificate Signing
// Request, eliminating the need for the server to ever hold or transmit
// the agent's private key. This reduces the attack surface significantly
// — the private key never traverses the network and never resides in
// server memory beyond the signing operation.
//
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
let pki = PkiBundle { let pki = PkiBundle {
ca_crt: issued.ca_root_pem, ca_crt: issued.ca_root_pem,
server_crt: issued.server_cert_pem, server_crt: issued.server_cert_pem,
server_key: issued.server_key_pem, server_key: issued.server_key_pem,
}; };
state state.approved_enrollments.insert(
.approved_enrollments enrollment_request.polling_token.clone(),
.insert(enrollment_request.polling_token.clone(), pki); ApprovedEntry::new(pki),
);
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }