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
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:
committed by
GitHub
parent
80ffb6b62f
commit
5aec9e629c
@ -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
206
crates/pm-ca/src/ca.rs
Executable file → Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
4
crates/pm-web/src/routes/mod.rs
Executable file → Normal 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;
|
||||
|
||||
61
crates/pm-web/src/routes/pki.rs
Normal file
61
crates/pm-web/src/routes/pki.rs
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user