diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 99d64667e..2516aad34 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -2,6 +2,7 @@ use std::{net::IpAddr, str::FromStr}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::NaiveDateTime; +pub use rcgen::ExtendedKeyUsagePurpose; use rcgen::{ BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, IsCa, Issuer, KeyPair, KeyUsagePurpose, SigningKey, string::Ia5String, @@ -14,8 +15,6 @@ use x509_parser::{ parse_x509_certificate, }; -pub use rcgen::ExtendedKeyUsagePurpose; - const CA_NAME: &str = "Defguard CA"; const NOT_BEFORE_OFFSET_SECS: Duration = Duration::minutes(5); const DEFAULT_CERT_VALIDITY_DAYS: i64 = 1825; @@ -96,10 +95,6 @@ impl CertificateAuthority<'_> { Self::from_key_cert_params(ca_key_pair, ca_params) } - /// Sign a server-facing component certificate (`ServerAuth` EKU only). - /// - /// Use [`sign_client_cert`] for Core gRPC client certificates, or - /// [`sign_csr_with_validity`] when custom validity is needed. pub fn sign_server_cert(&self, csr: &Csr) -> Result { self.sign_csr_with_validity( csr, @@ -108,7 +103,6 @@ impl CertificateAuthority<'_> { ) } - /// Sign a Core gRPC client certificate (`ClientAuth` EKU only). pub fn sign_client_cert(&self, csr: &Csr) -> Result { self.sign_csr_with_validity( csr, @@ -117,11 +111,6 @@ impl CertificateAuthority<'_> { ) } - /// Sign a CSR with explicit validity in days and extended key usages. - /// - /// `extended_key_usages` controls which EKUs are encoded in the signed - /// certificate. Pass `&[ServerAuth]` for component server certs and - /// `&[ClientAuth]` for Core gRPC client certs. pub fn sign_csr_with_validity( &self, csr: &Csr, @@ -397,6 +386,21 @@ pub type RcGenKeyPair = rcgen::KeyPair; mod tests { use super::*; + // Generated with: + // openssl ecparam -name prime256v1 -genkey -noout -out device.key + // openssl req -new -key device.key \ + // -subj "/CN=device.example.com" \ + // -addext "subjectAltName=DNS:device.example.com" \ + // -out device.csr + const OPENSSL_P256_CSR_PEM: &str = "-----BEGIN CERTIFICATE REQUEST----- +MIIBBzCBrwIBADAdMRswGQYDVQQDDBJkZXZpY2UuZXhhbXBsZS5jb20wWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAARd5+5mjOxyatISxK98hF2LmOwsjuFOlCQbe7u7 +vTJ70sC39Z9U8u4BwbSUl2fyRuKMwOCMt29dffKFoJz4EvMRoDAwLgYJKoZIhvcN +AQkOMSEwHzAdBgNVHREEFjAUghJkZXZpY2UuZXhhbXBsZS5jb20wCgYIKoZIzj0E +AwIDRwAwRAIgb38FDcxhdMUoGb+wDM8wHtVjKO2bKjxOMdbEloxhxK0CIHJMIxiu +mHNLSdvm1lY8N5VL6VyZMtaGi1jjF0en7drb +-----END CERTIFICATE REQUEST-----"; + #[test] fn test_to_from_der() { let key_pair = KeyPair::generate().unwrap(); @@ -553,11 +557,8 @@ mod tests { #[test] fn test_parse_pem_certificate() { - // Create a CA and get its PEM representation let ca = CertificateAuthority::new("Defguard CA", "test@example.com", 365).unwrap(); let pem = ca.cert_pem().unwrap(); - - // Parse the PEM back to DER and ensure it matches the original let parsed = parse_pem_certificate(&pem).unwrap(); assert_eq!(parsed, ca.cert_der); } @@ -592,6 +593,97 @@ mod tests { ); } + #[test] + fn test_sign_external_p256_csr_via_from_der() { + use rcgen::PKCS_ECDSA_P256_SHA256; + + let device_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let csr_built = Csr::new( + &device_key, + &["device.example.com".to_string()], + vec![(rcgen::DnType::CommonName, "device.example.com")], + ) + .unwrap(); + + let csr = Csr::from_der(csr_built.to_der()).unwrap(); + + let ca = CertificateAuthority::new("Defguard CA", "ca@example.com", 365).unwrap(); + let signed = ca.sign_server_cert(&csr).unwrap(); + + let (_, parsed) = x509_parser::parse_x509_certificate(signed.der()).unwrap(); + // ecPublicKey OID: 1.2.840.10045.2.1 + assert_eq!( + parsed + .tbs_certificate + .subject_pki + .algorithm + .algorithm + .to_id_string(), + "1.2.840.10045.2.1", + ); + assert_eq!( + parsed.tbs_certificate.subject_pki.subject_public_key.data, + device_key.public_key_raw(), + ); + } + + #[test] + fn test_sign_openssl_p256_csr() { + let csr = csr_from_pem(OPENSSL_P256_CSR_PEM); + let ca = CertificateAuthority::new("Defguard CA", "ca@example.com", 365).unwrap(); + let signed = ca.sign_server_cert(&csr).unwrap(); + let (_, parsed) = x509_parser::parse_x509_certificate(signed.der()).unwrap(); + assert_p256_spki(&parsed); + assert_eku( + &parsed, /* client_auth */ false, /* server_auth */ true, + ); + } + + #[test] + fn test_sign_openssl_p256_csr_client_cert() { + let csr = csr_from_pem(OPENSSL_P256_CSR_PEM); + let ca = CertificateAuthority::new("Defguard CA", "ca@example.com", 365).unwrap(); + let signed = ca.sign_client_cert(&csr).unwrap(); + let (_, parsed) = x509_parser::parse_x509_certificate(signed.der()).unwrap(); + assert_p256_spki(&parsed); + assert_eku( + &parsed, /* client_auth */ true, /* server_auth */ false, + ); + } + + fn csr_from_pem(pem: &str) -> Csr<'static> { + let b64: String = pem.lines().filter(|l| !l.starts_with("-----")).collect(); + let der = BASE64_STANDARD.decode(b64).unwrap(); + Csr::from_der(&der).unwrap() + } + + fn assert_p256_spki(parsed: &x509_parser::certificate::X509Certificate<'_>) { + let spki = &parsed.tbs_certificate.subject_pki; + // ecPublicKey OID: 1.2.840.10045.2.1 + assert_eq!(spki.algorithm.algorithm.to_id_string(), "1.2.840.10045.2.1"); + // uncompressed P-256 point: 0x04 || X || Y + assert_eq!(spki.subject_public_key.data.len(), 65); + } + + fn assert_eku( + parsed: &x509_parser::certificate::X509Certificate<'_>, + expect_client_auth: bool, + expect_server_auth: bool, + ) { + use x509_parser::extensions::ParsedExtension; + let eku = parsed + .tbs_certificate + .extensions() + .iter() + .find_map(|ext| match ext.parsed_extension() { + ParsedExtension::ExtendedKeyUsage(eku) => Some(eku.clone()), + _ => None, + }) + .expect("ExtendedKeyUsage extension must be present"); + assert_eq!(eku.client_auth, expect_client_auth); + assert_eq!(eku.server_auth, expect_server_auth); + } + #[test] fn test_csr_verify_hostname_extra_san_rejected() { let key = generate_key_pair().unwrap(); diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index 81d804ff5..d7e8b6704 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -62,6 +62,8 @@ use tokio::{ time::sleep, }; use tokio_stream::wrappers::UnboundedReceiverStream; +#[cfg(test)] +use tonic::transport::Endpoint; use tonic::{ Code, Request, Streaming, service::interceptor::InterceptedService, transport::Channel, }; @@ -72,8 +74,6 @@ use crate::{ HandlerTxMap, ProxyError, ProxyTxSet, TEN_SECS, servers::{EnrollmentServer, PasswordResetServer}, }; -#[cfg(test)] -use tonic::transport::Endpoint; const VERSION_ZERO: Version = Version::new(0, 0, 0); diff --git a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs index deaec3553..cf61eb351 100644 --- a/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs +++ b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs @@ -28,9 +28,8 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::time::timeout; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use crate::common::SESSION_COOKIE_NAME; - use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; +use crate::common::SESSION_COOKIE_NAME; fn init_tracing_once() { static ONCE: Once = Once::new(); diff --git a/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs index 289558ded..59e285419 100644 --- a/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs +++ b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs @@ -22,11 +22,10 @@ use reqwest::{ }; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - -use crate::common::{SESSION_COOKIE_NAME, TestClient}; +use tokio::{sync::oneshot, time::timeout}; use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; -use tokio::{sync::oneshot, time::timeout}; +use crate::common::{SESSION_COOKIE_NAME, TestClient}; async fn bootstrap_wizard_to_url_settings( pool: &sqlx::PgPool, diff --git a/crates/defguard_setup/tests/integration/initial_setup.rs b/crates/defguard_setup/tests/integration/initial_setup.rs index 08816b2b3..527c6d868 100644 --- a/crates/defguard_setup/tests/integration/initial_setup.rs +++ b/crates/defguard_setup/tests/integration/initial_setup.rs @@ -34,9 +34,8 @@ use tokio::{ time::timeout, }; -use crate::common::SESSION_COOKIE_NAME; - use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; +use crate::common::SESSION_COOKIE_NAME; async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { let step = InitialSetupState::get(pool) diff --git a/crates/defguard_setup/tests/integration/migration_wizard.rs b/crates/defguard_setup/tests/integration/migration_wizard.rs index d7f9d72c4..356498254 100644 --- a/crates/defguard_setup/tests/integration/migration_wizard.rs +++ b/crates/defguard_setup/tests/integration/migration_wizard.rs @@ -15,11 +15,11 @@ use reqwest::{ }; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use tokio::time::timeout; use super::common::{ SHUTDOWN_TIMEOUT, init_settings_with_secret_key, make_migration_test_client, seed_admin_user, }; -use tokio::time::timeout; async fn assert_migration_step(pool: &sqlx::PgPool, expected_variant: &str) { let state = MigrationWizardState::get(pool)