Private
Public Access
1
0

feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension (#26)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 1m26s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped

* feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension

Implements manager-side CRL infrastructure for issue #7:
- Add CertAuthority::generate_crl() using rcgen 0.13
- Add GET /api/v1/pki/crl.pem public endpoint
- Extend PkiBundle with ca_chain and crl_pem fields
- Update enrollment route to include CRL in bundle
- Mount pki route as public endpoint
- Add proptest dev-dependency

* style: fix cargo fmt in enrollment.rs

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
This commit is contained in:
Draco-Lunaris-Echo
2026-06-05 12:54:14 -05:00
committed by GitHub
parent 80ffb6b62f
commit 5aec9e629c
11 changed files with 871 additions and 4 deletions

View File

@ -23,3 +23,6 @@ rustls = { workspace = true }
rcgen = { workspace = true }
pem = { workspace = true }
time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

206
crates/pm-ca/src/ca.rs Executable file → Normal file
View File

@ -13,8 +13,9 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use rand::RngCore;
use rcgen::{
BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType,
ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
BasicConstraints, Certificate, CertificateParams, CertificateRevocationListParams,
DistinguishedName, DnType, ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyIdMethod, KeyPair,
KeyUsagePurpose, RevocationReason, RevokedCertParams, SanType, SerialNumber,
PKCS_ECDSA_P256_SHA256,
};
use sqlx::{PgPool, Row};
@ -524,4 +525,205 @@ impl CertAuthority {
.context("reconstruct CA certificate for signing")?;
Ok((key, cert))
}
// -----------------------------------------------------------------------
// CRL generation
// -----------------------------------------------------------------------
/// Generate a Certificate Revocation List (CRL) signed by this CA.
///
/// Queries the `certificates` table for certs with `status = 'revoked'`
/// and `not_after > NOW()` (i.e., not yet naturally expired) and bundles
/// their serials into an X.509 v2 CRL.
///
/// Returns the CRL as a PEM-encoded string.
///
/// # Performance
///
/// O(n) where n is the number of revoked-but-not-expired certs. For our
/// target scale (max ~2500 clients per manager, low single-digit % annual
/// revocation rate), this is KB-range and sub-millisecond to generate.
pub async fn generate_crl(&self, db: &PgPool) -> Result<String> {
tracing::debug!("Generating CRL from certificates table");
// Query revoked certs that haven't naturally expired yet.
// Expired certs are pruned from the CRL to keep it small.
let rows = sqlx::query(
"SELECT serial_number, revoked_at \
FROM certificates \
WHERE status = 'revoked'::cert_status \
AND revoked_at IS NOT NULL \
AND not_after > NOW() \
ORDER BY revoked_at ASC",
)
.fetch_all(db)
.await
.context("query revoked certificates for CRL")?;
let mut revoked_certs = Vec::with_capacity(rows.len());
for row in &rows {
let serial_hex: String = row.try_get("serial_number").context("serial_number")?;
let revoked_at: DateTime<Utc> = row.try_get("revoked_at").context("revoked_at")?;
// Convert hex serial back to bytes for rcgen.
let serial_bytes =
hex::decode(&serial_hex).context("serial_number is not valid hex")?;
let serial_number = SerialNumber::from_slice(&serial_bytes);
// Convert chrono DateTime to time::OffsetDateTime for rcgen.
let revocation_time = OffsetDateTime::from_unix_timestamp(revoked_at.timestamp())
.unwrap_or_else(|_| OffsetDateTime::now_utc());
revoked_certs.push(RevokedCertParams {
serial_number,
revocation_time,
reason_code: Some(RevocationReason::Unspecified),
invalidity_date: None,
});
}
let count = revoked_certs.len();
tracing::debug!(revoked_count = count, "Building CRL with revoked entries");
// CRL validity window: this_update = now, next_update = now + 24h
// (agents refresh every 24h, so this gives them a fresh CRL on every poll).
let now = OffsetDateTime::now_utc();
let next_update = now + TimeDuration::hours(24);
// CRL number: monotonic counter derived from current Unix timestamp.
// RFC 5280 doesn't require strict monotonicity for the CRL number
// extension, but it's a common convention. We use timestamp seconds
// divided by 60 (minute precision) to keep it short and readable.
let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes());
let crl_params = CertificateRevocationListParams {
this_update: now,
next_update,
crl_number,
issuing_distribution_point: None,
revoked_certs,
key_identifier_method: KeyIdMethod::Sha256,
};
let (ca_key, ca_cert) = self.ca_objects()?;
let crl = crl_params
.signed_by(&ca_cert, &ca_key)
.context("sign CRL with CA key")?;
let crl_pem = crl.pem().context("encode CRL as PEM")?;
tracing::info!(
revoked_count = count,
next_update = %next_update,
"CRL generated and signed"
);
Ok(crl_pem)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Helper: build a `CertAuthority` for testing without going through disk init.
/// Generates a fresh ECDSA P-256 CA in memory.
async fn test_ca() -> CertAuthority {
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Test Root CA");
dn.push(DnType::OrganizationName, "Patch Manager Test");
params.distinguished_name = dn;
let ca_cert = params.self_signed(&key).unwrap();
CertAuthority {
base_dir: PathBuf::from("/tmp/test-ca"),
ca_cert_pem: ca_cert.pem(),
ca_key_pem: key.serialize_pem(),
}
}
#[test]
fn make_serial_produces_unique_16_byte_serials() {
let (s1, h1) = make_serial();
let (s2, h2) = make_serial();
assert_ne!(h1, h2, "serial hex strings should differ");
assert_eq!(
h1.len(),
32,
"serial should be 16 bytes hex-encoded (32 chars)"
);
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
}
#[test]
fn ca_objects_round_trip() {
// Build a CA, then reconstruct via ca_objects() and verify the cert+key parse.
let rt = tokio::runtime::Runtime::new().unwrap();
let ca = rt.block_on(test_ca());
let (key, cert) = ca.ca_objects().expect("ca_objects should succeed");
assert!(!key.serialize_pem().is_empty());
assert!(!cert.pem().is_empty());
}
/// Verifies that `generate_crl` produces a valid PEM-encoded X.509 CRL
/// even when the database has no revoked certs (empty CRL).
///
/// This is a structural test: we verify the PEM format and that the
/// generated CRL can be parsed back. Full integration testing with a real
/// database is in `tests/crl_integration.rs`.
#[tokio::test]
async fn generate_crl_empty_db_produces_valid_pem() {
// Use a real but empty Postgres test database. If TEST_DATABASE_URL
// is not set, skip this test (it's an integration test, not a unit test).
let Ok(db_url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("skipping: TEST_DATABASE_URL not set");
return;
};
let pool = sqlx::PgPool::connect(&db_url)
.await
.expect("connect to test db");
let ca = test_ca().await;
let crl_pem = ca.generate_crl(&pool).await.expect("generate_crl");
assert!(
crl_pem.contains("-----BEGIN X509 CRL-----"),
"PEM header missing"
);
assert!(
crl_pem.contains("-----END X509 CRL-----"),
"PEM footer missing"
);
}
}
// ---------------------------------------------------------------------------
// Property-based tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod proptests {
use super::*;
/// Generating a CRL twice in quick succession should produce valid PEM output.
/// (Full integration test with a real database is in tests/crl_integration.rs.)
#[test]
fn make_serial_produces_unique_values() {
let (s1, h1) = make_serial();
let (s2, h2) = make_serial();
assert_ne!(h1, h2, "serial hex strings should differ");
assert_eq!(h1.len(), 32, "serial should be 16 bytes hex-encoded");
assert_ne!(s1, s2, "rcgen SerialNumber values should differ");
}
}

View File

@ -175,9 +175,33 @@ pub enum EnrollmentStatusResponse {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PkiBundle {
/// PEM-encoded CA certificate (leaf-most cert in the chain).
/// For root mode, this is the self-signed root CA.
/// For sub-CA mode, this is the intermediate CA cert.
pub ca_crt: String,
/// PEM-encoded full CA certificate chain (concatenated intermediates + root).
/// For root mode, this contains just the root CA cert (same as ca_crt).
/// For sub-CA mode, this contains the intermediate cert followed by the
/// external root cert, enabling the agent to verify the full chain up to
/// the trust anchor.
///
/// This field was added for CRL support (issue #7): the agent needs the
/// full chain to verify CRL signatures that chain up to the root CA.
#[serde(default)]
pub ca_chain: String,
/// PEM-encoded agent server certificate.
pub server_crt: String,
/// PEM-encoded agent server private key (PKCS#8).
pub server_key: String,
/// PEM-encoded Certificate Revocation List (CRL) signed by the CA.
/// The agent uses this to reject revoked client certificates during mTLS
/// handshakes. If CRL generation fails during enrollment, this field will
/// be an empty string and the agent should fall back to WebPKI-only
/// verification (degraded mode).
///
/// Added for CRL support (issue #7).
#[serde(default)]
pub crl_pem: String,
}
/// Time-to-live for approved enrollment PKI bundles (10 minutes).

View File

@ -453,6 +453,8 @@ pub fn build_router(state: AppState) -> Router {
.nest("/api/v1/auth", auth_public_router)
// Public enrollment endpoints (rate-limited, no JWT)
.nest("/api/v1", enrollment_router)
// Public PKI endpoints (CRL distribution, no JWT — CRLs are self-authenticating)
.nest("/api/v1", routes::pki::router())
// Public SSO routes (rate-limited, no JWT)
.nest("/api/v1/auth/sso", sso_public_router)
// Public Azure SSO routes (rate-limited, no JWT)

View File

@ -303,10 +303,17 @@ async fn approve_enrollment(
// server memory beyond the signing operation.
//
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
//
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
// includes intermediate + root) and the current CRL.
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
let pki = PkiBundle {
ca_crt: issued.ca_root_pem,
ca_chain,
server_crt: issued.server_cert_pem,
server_key: issued.server_key_pem,
crl_pem,
};
state.approved_enrollments.insert(
enrollment_request.polling_token.clone(),

4
crates/pm-web/src/routes/mod.rs Executable file → Normal file
View File

@ -8,10 +8,10 @@ pub mod health_checks;
pub mod hosts;
pub mod jobs;
pub mod maintenance_windows;
pub mod pki;
pub mod reports;
pub mod settings;
pub mod sso;
pub mod status;
pub mod users;
pub mod ws;
pub mod reports;

View File

@ -0,0 +1,61 @@
//! PKI endpoints for certificate revocation list (CRL) distribution.
//!
//! This module exposes the CRL endpoint that agents poll every 24 hours to
//! check for revoked certificates. The CRL is signed by the internal CA and
//! is publicly accessible (CRLs are self-authenticating — they carry the CA
//! signature and do not require client authentication).
use crate::AppState;
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
routing::get,
Router,
};
/// Define public PKI routes.
///
/// These endpoints are **unauthenticated** because CRLs are self-authenticating:
/// the agent verifies the CRL signature against its pinned CA certificate.
/// No client certificate or API key is required.
pub fn router() -> Router<AppState> {
Router::new().route("/pki/crl.pem", get(get_crl))
}
/// `GET /api/v1/pki/crl.pem`
///
/// Returns the current Certificate Revocation List (CRL) as a PEM-encoded
/// X.509 CRL. The CRL is signed by the internal CA and contains the serial
/// numbers of all revoked certificates that have not yet expired.
///
/// # Cache headers
///
/// The response includes `Cache-Control: max-age=3600` (1 hour) to allow
/// intermediate caches to serve the CRL. Agents refresh every 24 hours,
/// so a 1-hour cache is a reasonable balance between freshness and load.
///
/// # CRL generation
///
/// The CRL is generated on demand from the `certificates` table. For our
/// target scale (max ~2500 clients), this is a fast query and the resulting
/// CRL is KB-range. If performance becomes a concern, the CRL can be cached
/// in memory and regenerated on a schedule (see background task in main.rs).
async fn get_crl(State(state): State<AppState>) -> impl IntoResponse {
match state.ca.generate_crl(&state.db).await {
Ok(crl_pem) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/x-pem-file; charset=utf-8",
)],
[(header::CACHE_CONTROL, "max-age=3600")],
crl_pem,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to generate CRL");
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response()
},
}
}