fix: updater uses TOFU-pinned cert validation instead of disabling TLS (BUG-134)

Replace danger_accept_invalid_certs(true) with PinnedVerifier-based
rustls config that validates server cert against TOFU fingerprint from
the cert store. For CA-signed servers (no stored fingerprint), system
TLS is used. Shared PinnedVerifier, cert_store_key, and
load_stored_fingerprint are now pub(crate) for reuse.
This commit is contained in:
J3vb
2026-04-02 12:41:49 +02:00
parent df9b5ab90f
commit e2a9811765
2 changed files with 67 additions and 14 deletions
@@ -68,20 +68,20 @@ impl LiveKitProxyState {
// ---------------------------------------------------------------------------
/// Tauri store file for certificate fingerprints (shared with ws_proxy).
const CERTS_STORE: &str = "certs.json";
pub(crate) const CERTS_STORE: &str = "certs.json";
/// Verifies the server certificate against a known SHA-256 fingerprint.
/// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same
/// host, so LiveKit connections are pinned to the same certificate the user
/// already trusted during WebSocket setup.
#[derive(Debug)]
struct PinnedVerifier {
pub(crate) struct PinnedVerifier {
/// Expected SHA-256 colon-hex fingerprint (e.g. "aa:bb:cc:...").
expected_fingerprint: String,
}
impl PinnedVerifier {
fn new(expected_fingerprint: String) -> Self {
pub(crate) fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint }
}
}
@@ -155,12 +155,12 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
/// Produce the cert store key matching ws_proxy's format.
/// ws_proxy extracts the host from "wss://host/path" which omits port 443.
/// We normalise by stripping the default ":443" suffix so the keys match.
fn cert_store_key(remote_host: &str) -> String {
pub(crate) fn cert_store_key(remote_host: &str) -> String {
remote_host.strip_suffix(":443").unwrap_or(remote_host).to_string()
}
/// Load the stored certificate fingerprint for a host from the Tauri cert store.
fn load_stored_fingerprint<R: Runtime>(
pub(crate) fn load_stored_fingerprint<R: Runtime>(
app: &tauri::AppHandle<R>,
host: &str,
) -> Result<Option<String>, String> {
@@ -1,7 +1,10 @@
use std::sync::Arc;
use serde::Serialize;
use tauri::AppHandle;
use tauri_plugin_updater::UpdaterExt;
use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier};
#[derive(Serialize)]
pub struct UpdateCheckResult {
pub available: bool,
@@ -9,6 +12,42 @@ pub struct UpdateCheckResult {
pub body: Option<String>,
}
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
let parsed = url::Url::parse(server_url)
.map_err(|e| format!("failed to parse server URL: {e}"))?;
let host = parsed.host_str()
.ok_or_else(|| "server URL has no host".to_string())?;
let port = parsed.port().unwrap_or(443);
let raw = if port == 443 {
host.to_string()
} else {
format!("{host}:{port}")
};
Ok(cert_store_key(&raw))
}
/// Build a rustls ClientConfig that validates the server cert against the
/// TOFU-pinned fingerprint. Falls back to system certs if no fingerprint
/// is stored (server uses a real CA cert).
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
let store_key = extract_host_for_cert_store(server_url)?;
let fingerprint = load_stored_fingerprint(app, &store_key)?;
match fingerprint {
Some(fp) => {
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinnedVerifier::new(fp)))
.with_no_client_auth();
Ok(Some(config))
}
None => {
// No TOFU fingerprint stored — use system TLS (works for CA-signed certs).
Ok(None)
}
}
}
/// Validate that a server URL is safe for the updater to connect to.
fn validate_server_url(server_url: &str) -> Result<(), String> {
let trimmed = server_url.trim_end_matches('/');
@@ -50,14 +89,20 @@ pub async fn check_client_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// OwnCord is self-hosted and commonly uses self-signed TLS certs.
// The updater connects to the user's own server, so accept invalid certs
// (the update artifact itself is verified via Ed25519 signature).
let updater = app
// Use TOFU-pinned certificate for self-signed servers, or system certs
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -106,11 +151,19 @@ pub async fn download_and_install_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
let updater = app
// Use TOFU-pinned certificate for self-signed servers (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;