From d456ae5837e72c5bc8297243969dc05bfc81d586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 18 Apr 2026 23:11:39 +0200 Subject: [PATCH] refactor(desktop): reuse packages/server TLS assets in Tauri Load server-managed TLS certificates (server-cert.pem, server-key.pem, ca-cert.pem) from the server's TLS directory instead of generating a separate proxy certificate in Tauri. Also trust the server CA in the Windows trust store instead of a self-signed proxy cert. This aligns with the reviewer feedback to avoid duplicating certificate management across the codebase. --- packages/tauri-app/Cargo.lock | 34 +-- packages/tauri-app/src-tauri/Cargo.toml | 2 +- .../tauri-app/src-tauri/src/cert_manager.rs | 271 +++++++++++------- packages/tauri-app/src-tauri/src/main.rs | 4 +- 4 files changed, 178 insertions(+), 133 deletions(-) diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 6e430c97..9df29d21 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -588,6 +588,7 @@ dependencies = [ "anyhow", "axum", "axum-server", + "base64 0.22.1", "bytes", "dirs 5.0.1", "futures-util", @@ -596,7 +597,6 @@ dependencies = [ "once_cell", "parking_lot", "rand 0.8.5", - "rcgen", "regex", "reqwest 0.12.28", "rustls", @@ -2837,16 +2837,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3427,19 +3417,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -6330,15 +6307,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yoke" version = "0.8.1" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 8433b7b0..c29ae8b0 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -14,9 +14,9 @@ serde_json = "1" serde_yaml = "0.9" axum = "0.7" axum-server = { version = "0.7", features = ["tls-rustls"] } +base64 = "0.22" bytes = "1" futures-util = "0.3" -rcgen = "0.13" rustls = { version = "0.23", features = ["ring"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] } rand = "0.8" diff --git a/packages/tauri-app/src-tauri/src/cert_manager.rs b/packages/tauri-app/src-tauri/src/cert_manager.rs index 9cfc9a7a..3372fa7c 100644 --- a/packages/tauri-app/src-tauri/src/cert_manager.rs +++ b/packages/tauri-app/src-tauri/src/cert_manager.rs @@ -1,126 +1,177 @@ -use rcgen::{CertificateParams, DnType, KeyPair, SanType}; +use base64::Engine; +use std::env; use std::fs; -use std::path::PathBuf; -use std::time::Duration; +use std::path::{Path, PathBuf}; -const CERT_DIR_NAME: &str = "proxy-certs"; -const CERT_PEM_FILE: &str = "proxy.crt"; -const KEY_PEM_FILE: &str = "proxy.key"; -const CERT_DER_FILE: &str = "proxy.der"; -const TRUSTED_MARKER: &str = ".trusted"; +const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; +const TLS_DIR_NAME: &str = "tls"; +const CA_CERT_FILE: &str = "ca-cert.pem"; +const SERVER_CERT_FILE: &str = "server-cert.pem"; +const SERVER_KEY_FILE: &str = "server-key.pem"; +const TRUSTED_MARKER: &str = "server-ca.trusted"; #[cfg(windows)] const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; -/// Holds PEM-encoded certificate and private key for the local HTTPS proxy. +/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy, +/// plus the CA certificate DER used for trust-store installation. pub struct LocalCert { pub cert_pem: String, pub key_pem: String, - pub cert_der: Vec, + pub ca_cert_der: Vec, } -/// Returns the directory where proxy certificates are stored. -fn cert_dir() -> Result { - let base = dirs::data_local_dir() - .ok_or_else(|| "Cannot determine local app data directory".to_string())?; - #[cfg(windows)] - { - return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(CERT_DIR_NAME)); - } - - #[cfg(not(windows))] - { - Ok(base.join("codenomad").join(CERT_DIR_NAME)) - } +struct TlsAssetPaths { + cert_path: PathBuf, + key_path: PathBuf, + trust_path: PathBuf, + append_ca_to_cert: bool, } -/// Ensures a self-signed certificate exists on disk, generating one if needed. -/// Returns the PEM cert, PEM key, and DER cert bytes. +/// Loads the TLS assets already managed by `packages/server`. pub fn ensure_local_cert() -> Result { - let dir = cert_dir()?; - let cert_pem_path = dir.join(CERT_PEM_FILE); - let key_pem_path = dir.join(KEY_PEM_FILE); - let cert_der_path = dir.join(CERT_DER_FILE); + let assets = resolve_tls_asset_paths()?; + let mut cert_pem = read_pem_file(&assets.cert_path)?; + let key_pem = read_pem_file(&assets.key_path)?; + let trust_pem = read_pem_file(&assets.trust_path)?; - // If all files exist, load from disk - if cert_pem_path.exists() && key_pem_path.exists() && cert_der_path.exists() { - let cert_pem = fs::read_to_string(&cert_pem_path) - .map_err(|e| format!("Failed to read {}: {e}", cert_pem_path.display()))?; - let key_pem = fs::read_to_string(&key_pem_path) - .map_err(|e| format!("Failed to read {}: {e}", key_pem_path.display()))?; - let cert_der = fs::read(&cert_der_path) - .map_err(|e| format!("Failed to read {}: {e}", cert_der_path.display()))?; - return Ok(LocalCert { - cert_pem, - key_pem, - cert_der, - }); + if assets.append_ca_to_cert { + cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim()); } - // Generate a new self-signed certificate - fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create cert dir {}: {e}", dir.display()))?; - - let mut params = CertificateParams::default(); - params - .distinguished_name - .push(DnType::CommonName, "CodeNomad Local Proxy"); - params.subject_alt_names = vec![ - SanType::DnsName("localhost".try_into().map_err(|e| format!("{e}"))?), - SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), - ]; - - // Valid for 10 years - params.not_before = rcgen::date_time_ymd(2024, 1, 1); - let ten_years = Duration::from_secs(10 * 365 * 24 * 3600); - params.not_after = params.not_before + ten_years; - - let key_pair = KeyPair::generate().map_err(|e| format!("Key generation failed: {e}"))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| format!("Certificate generation failed: {e}"))?; - - let cert_pem = cert.pem(); - let key_pem = key_pair.serialize_pem(); - let cert_der = cert.der().to_vec(); - - fs::write(&cert_pem_path, &cert_pem) - .map_err(|e| format!("Failed to write {}: {e}", cert_pem_path.display()))?; - fs::write(&key_pem_path, &key_pem) - .map_err(|e| format!("Failed to write {}: {e}", key_pem_path.display()))?; - fs::write(&cert_der_path, &cert_der) - .map_err(|e| format!("Failed to write {}: {e}", cert_der_path.display()))?; - - // Remove the trusted marker since this is a new cert - let marker = dir.join(TRUSTED_MARKER); - let _ = fs::remove_file(&marker); + let ca_cert_der = pem_to_der(&trust_pem)?; Ok(LocalCert { cert_pem, key_pem, - cert_der, + ca_cert_der, }) } +fn read_pem_file(path: &Path) -> Result { + fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display())) +} + +fn server_tls_dir() -> Result { + Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME)) +} + +fn resolve_tls_asset_paths() -> Result { + let tls_key_path = env::var("CLI_TLS_KEY") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_path_like_server(&value)) + .transpose()?; + let tls_cert_path = env::var("CLI_TLS_CERT") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_path_like_server(&value)) + .transpose()?; + let tls_ca_path = env::var("CLI_TLS_CA") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_path_like_server(&value)) + .transpose()?; + + match (tls_key_path, tls_cert_path) { + (Some(key_path), Some(cert_path)) => { + let append_ca_to_cert = tls_ca_path.is_some(); + let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone()); + Ok(TlsAssetPaths { + cert_path, + key_path, + trust_path, + append_ca_to_cert, + }) + } + (Some(_), None) | (None, Some(_)) => Err( + "CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files" + .to_string(), + ), + (None, None) => { + let tls_dir = server_tls_dir()?; + Ok(TlsAssetPaths { + cert_path: tls_dir.join(SERVER_CERT_FILE), + key_path: tls_dir.join(SERVER_KEY_FILE), + trust_path: tls_dir.join(CA_CERT_FILE), + append_ca_to_cert: true, + }) + } + } +} + +fn resolve_server_config_base_dir() -> Result { + let raw = env::var("CLI_CONFIG") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); + let expanded = resolve_path_like_server(&raw)?; + let lower = raw.trim().to_lowercase(); + + if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") { + return expanded + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display())); + } + + Ok(expanded) +} + +fn resolve_path_like_server(path: &str) -> Result { + if path.starts_with("~/") { + let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)); + let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?; + return Ok(home.join(path.trim_start_matches("~/"))); + } + + let path = PathBuf::from(path); + if path.is_absolute() { + return Ok(path); + } + + let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?; + Ok(cwd.join(path)) +} + fn trusted_marker_path() -> Result { - Ok(cert_dir()?.join(TRUSTED_MARKER)) + let base = dirs::data_local_dir() + .ok_or_else(|| "Cannot determine local app data directory".to_string())?; + + #[cfg(windows)] + { + return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER)); + } + + #[cfg(not(windows))] + { + Ok(base.join("codenomad").join(TRUSTED_MARKER)) + } } -fn write_trusted_marker() -> Result<(), String> { - fs::write(trusted_marker_path()?, "trusted") - .map_err(|e| format!("Failed to write trust marker: {e}")) +fn trusted_marker_value(cert_der: &[u8]) -> String { + cert_der.iter().map(|byte| format!("{byte:02x}")).collect() } -/// Returns true if the certificate has already been added to the OS trust store -/// (indicated by the `.trusted` marker file). -pub fn is_cert_trusted() -> bool { +fn has_matching_trusted_marker(cert_der: &[u8]) -> bool { trusted_marker_path() - .map(|path| path.exists()) + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + .map(|value| value.trim() == trusted_marker_value(cert_der)) .unwrap_or(false) } -/// Adds the DER-encoded certificate to the Windows `CurrentUser\Root` store. -/// This will show a one-time Windows security confirmation dialog. -/// After success, writes a `.trusted` marker file to avoid re-prompting. +fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> { + let path = trusted_marker_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?; + } + fs::write(path, trusted_marker_value(cert_der)) + .map_err(|e| format!("Failed to write trust marker: {e}")) +} + +/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store. +/// This will show a one-time Windows security confirmation dialog when needed. #[cfg(windows)] pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> { use windows_sys::Win32::Security::Cryptography::{ @@ -128,11 +179,10 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> { CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING, }; - if is_cert_trusted() { + if has_matching_trusted_marker(cert_der) { return Ok(()); } - // "Root" in UTF-16 let store_name: Vec = "Root\0".encode_utf16().collect(); unsafe { @@ -154,13 +204,14 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> { CertCloseStore(store, 0); if result == 0 { - return Err("Failed to add certificate to trust store. \ - The user may have declined the security dialog." - .into()); + return Err( + "Failed to add certificate to trust store. The user may have declined the security dialog." + .into(), + ); } } - write_trusted_marker()?; + write_trusted_marker(cert_der)?; Ok(()) } @@ -169,3 +220,29 @@ pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> { // Non-Windows platforms use native webview-specific handling instead of OS trust-store writes. Ok(()) } + +fn pem_to_der(pem: &str) -> Result, String> { + let mut body = String::new(); + let mut in_block = false; + + for line in pem.lines() { + if line.starts_with("-----BEGIN CERTIFICATE-----") { + in_block = true; + continue; + } + if line.starts_with("-----END CERTIFICATE-----") { + break; + } + if in_block { + body.push_str(line.trim()); + } + } + + if body.is_empty() { + return Err("No certificate found in PEM file".to_string()); + } + + base64::engine::general_purpose::STANDARD + .decode(body) + .map_err(|e| format!("Failed to decode certificate PEM: {e}")) +} diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 0ae27714..52de199c 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -308,9 +308,9 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res #[cfg(not(target_os = "linux"))] let tls_config = match cert_manager::ensure_local_cert() { Ok(local_cert) => { - if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.cert_der) { + if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) { return Err(format!( - "Failed to trust the local proxy certificate. Accept the certificate installation prompt and try again: {err}" + "Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}" )); } Some(ProxyTlsConfig {