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.
This commit is contained in:
Pascal André
2026-04-18 23:11:39 +02:00
parent 3ec1598bbd
commit d456ae5837
4 changed files with 178 additions and 133 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<u8>,
pub ca_cert_der: Vec<u8>,
}
/// Returns the directory where proxy certificates are stored.
fn cert_dir() -> Result<PathBuf, String> {
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<LocalCert, String> {
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<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<u16> = "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<Vec<u8>, 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}"))
}

View File

@@ -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 {