Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/lpm-cert/src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, SanType};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::Path;
use time::{Duration, OffsetDateTime};
use x509_parser::extensions::GeneralName;

/// Certificate info extracted from an existing cert file.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -99,6 +100,35 @@ pub fn needs_renewal(cert_path: &Path) -> Result<bool, LpmError> {
Ok(not_after <= renewal_threshold)
}

/// Check whether a certificate already covers the requested extra hostnames.
pub fn covers_requested_hostnames(
cert_path: &Path,
requested_hostnames: &[String],
) -> Result<bool, LpmError> {
if requested_hostnames.is_empty() {
return Ok(true);
}

let pem_str = std::fs::read_to_string(cert_path)
.map_err(|e| LpmError::Cert(format!("failed to read cert: {e}")))?;

let pem = pem::parse(&pem_str).map_err(|e| LpmError::Cert(format!("invalid PEM: {e}")))?;

let (_, cert) = x509_parser::parse_x509_certificate(pem.contents())
.map_err(|e| LpmError::Cert(format!("invalid X.509: {e}")))?;

let Some(san) = cert.subject_alternative_name().ok().flatten() else {
return Ok(false);
};

Ok(requested_hostnames.iter().all(|requested_hostname| {
san.value
.general_names
.iter()
.any(|name| general_name_matches_requested_host(name, requested_hostname))
}))
}

/// Read certificate information from a PEM file.
pub fn read_cert_info(cert_path: &Path) -> Result<CertInfo, LpmError> {
let pem_str = std::fs::read_to_string(cert_path)
Expand Down Expand Up @@ -164,6 +194,18 @@ fn format_asn1_time(time: &x509_parser::time::ASN1Time) -> String {
format!("{}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
}

fn general_name_matches_requested_host(name: &GeneralName<'_>, requested_host: &str) -> bool {
if let Ok(ip) = requested_host.parse::<IpAddr>() {
return match (ip, name) {
(IpAddr::V4(ip), GeneralName::IPAddress(bytes)) => *bytes == ip.octets().as_slice(),
(IpAddr::V6(ip), GeneralName::IPAddress(bytes)) => *bytes == ip.octets().as_slice(),
_ => false,
};
}

matches!(name, GeneralName::DNSName(hostname) if *hostname == requested_host)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -293,4 +335,17 @@ mod tests {
assert!(!info.is_ca);
assert!(!info.san_entries.is_empty());
}

#[test]
fn covers_requested_hostnames_detects_missing_requested_sans() {
let (ca_cert_pem, ca_key_pem) = ca::generate_ca().unwrap();
let extras = vec!["myapp.test".to_string(), "192.168.1.42".to_string()];
let (cert_pem, _) = generate_project_cert(&ca_cert_pem, &ca_key_pem, &extras).unwrap();

let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &cert_pem).unwrap();

assert!(covers_requested_hostnames(tmp.path(), &extras).unwrap());
assert!(!covers_requested_hostnames(tmp.path(), &["missing.test".to_string()]).unwrap());
}
}
50 changes: 26 additions & 24 deletions crates/lpm-cert/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,30 +127,32 @@ pub fn ensure_https(
let proj_cert_path = project_cert_dir.join("cert.pem");
let proj_key_path = project_cert_dir.join("key.pem");

let cert_freshly_generated =
if !proj_cert_path.exists() || cert::needs_renewal(&proj_cert_path)? {
tracing::info!("generating project certificate...");
std::fs::create_dir_all(&project_cert_dir)
.map_err(|e| LpmError::Cert(format!("failed to create project cert dir: {e}")))?;

let ca_cert_pem = std::fs::read_to_string(paths::ca_cert_path()?)
.map_err(|e| LpmError::Cert(format!("failed to read CA cert: {e}")))?;
let ca_key_pem = std::fs::read_to_string(paths::ca_key_path()?)
.map_err(|e| LpmError::Cert(format!("failed to read CA key: {e}")))?;

let (cert_pem, key_pem) =
cert::generate_project_cert(&ca_cert_pem, &ca_key_pem, extra_hostnames)
.map_err(|e| LpmError::Cert(format!("failed to generate project cert: {e}")))?;

std::fs::write(&proj_cert_path, &cert_pem)
.map_err(|e| LpmError::Cert(format!("failed to write project cert: {e}")))?;
write_key_file(&proj_key_path, key_pem.as_bytes())
.map_err(|e| LpmError::Cert(format!("failed to write project key: {e}")))?;

true
} else {
false
};
let cert_freshly_generated = if !proj_cert_path.exists()
|| cert::needs_renewal(&proj_cert_path)?
|| !cert::covers_requested_hostnames(&proj_cert_path, extra_hostnames)?
{
tracing::info!("generating project certificate...");
std::fs::create_dir_all(&project_cert_dir)
.map_err(|e| LpmError::Cert(format!("failed to create project cert dir: {e}")))?;

let ca_cert_pem = std::fs::read_to_string(paths::ca_cert_path()?)
.map_err(|e| LpmError::Cert(format!("failed to read CA cert: {e}")))?;
let ca_key_pem = std::fs::read_to_string(paths::ca_key_path()?)
.map_err(|e| LpmError::Cert(format!("failed to read CA key: {e}")))?;

let (cert_pem, key_pem) =
cert::generate_project_cert(&ca_cert_pem, &ca_key_pem, extra_hostnames)
.map_err(|e| LpmError::Cert(format!("failed to generate project cert: {e}")))?;

std::fs::write(&proj_cert_path, &cert_pem)
.map_err(|e| LpmError::Cert(format!("failed to write project cert: {e}")))?;
write_key_file(&proj_key_path, key_pem.as_bytes())
.map_err(|e| LpmError::Cert(format!("failed to write project key: {e}")))?;

true
} else {
false
};

// Step 3: Build env vars for the dev server
let ca_cert_path_str = paths::ca_cert_path()?.to_string_lossy().to_string();
Expand Down
45 changes: 45 additions & 0 deletions crates/lpm-cert/src/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ use std::process::Command;

#[cfg(any(target_os = "macos", target_os = "windows"))]
const CA_COMMON_NAME: &str = "LPM Local Development CA";
const TEST_TRUST_STORE_DIR_ENV: &str = "LPM_CERT_TEST_TRUST_STORE_DIR";

fn test_trust_store_path() -> Option<std::path::PathBuf> {
std::env::var_os(TEST_TRUST_STORE_DIR_ENV)
.map(std::path::PathBuf::from)
.map(|dir| dir.join("lpm-local-ca.pem"))
}

fn install_ca_test(ca_cert_path: &Path, trust_store_path: &Path) -> Result<(), LpmError> {
if let Some(parent) = trust_store_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LpmError::Cert(format!("failed to create test trust store: {e}")))?;
}

std::fs::copy(ca_cert_path, trust_store_path)
.map_err(|e| LpmError::Cert(format!("failed to install CA to test trust store: {e}")))?;

Ok(())
}

fn is_ca_installed_test(trust_store_path: &Path) -> bool {
trust_store_path.exists()
}

fn uninstall_ca_test(trust_store_path: &Path) -> Result<(), LpmError> {
if trust_store_path.exists() {
std::fs::remove_file(trust_store_path).map_err(|e| {
LpmError::Cert(format!("failed to remove CA from test trust store: {e}"))
})?;
}

Ok(())
}

/// Install the CA certificate into the system trust store.
///
Expand All @@ -17,6 +50,10 @@ const CA_COMMON_NAME: &str = "LPM Local Development CA";
/// - Linux: copies to ca-certificates dir + runs update-ca-certificates (needs sudo)
/// - Windows: uses certutil to add to Root store (UAC prompt)
pub fn install_ca(ca_cert_path: &Path) -> Result<(), LpmError> {
if let Some(trust_store_path) = test_trust_store_path() {
return install_ca_test(ca_cert_path, &trust_store_path);
}

let path_str = ca_cert_path.to_string_lossy();

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -46,6 +83,10 @@ pub fn install_ca(ca_cert_path: &Path) -> Result<(), LpmError> {

/// Check if the LPM CA is currently installed in the system trust store.
pub fn is_ca_installed(_ca_cert_path: &Path) -> Result<bool, LpmError> {
if let Some(trust_store_path) = test_trust_store_path() {
return Ok(is_ca_installed_test(&trust_store_path));
}

#[cfg(target_os = "macos")]
{
is_ca_installed_macos()
Expand All @@ -70,6 +111,10 @@ pub fn is_ca_installed(_ca_cert_path: &Path) -> Result<bool, LpmError> {

/// Remove the LPM CA from the system trust store.
pub fn uninstall_ca() -> Result<(), LpmError> {
if let Some(trust_store_path) = test_trust_store_path() {
return uninstall_ca_test(&trust_store_path);
}

#[cfg(target_os = "macos")]
{
uninstall_ca_macos()
Expand Down
2 changes: 2 additions & 0 deletions tests/workflows/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ tokio = { version = "1", features = ["full"] }
chrono = { workspace = true }
rcgen = { version = "0.13", features = ["pem"] }
reqwest = { workspace = true }
lpm-common = { path = "../../crates/lpm-common" }
lpm-runner = { path = "../../crates/lpm-runner" }
lpm-vault = { path = "../../crates/lpm-vault" }
lpm-lockfile = { path = "../../crates/lpm-lockfile" }
lpm-security = { path = "../../crates/lpm-security" }
Expand Down
Loading
Loading