fix(security): gate TLS trust-on-first-use behind explicit confirmation (F4/F8)

The http and ws proxies accepted ANY certificate on first use and silently
pinned it, forwarding login credentials and the bearer token before the user
ever saw the fingerprint — an on-path attacker at first contact captured them.
The three proxies also duplicated the TLS verifier and TOFU logic verbatim.

- Extract the shared verifier, cert-store helpers, and a pure `decide` function
  into src-tauri/src/tofu.rs (used by the http/ws/livekit proxies).
- Split the trust decision from persistence: a first-use cert is no longer
  pinned or forwarded to. The proxy rejects (ws: Err; http: 502) and emits a
  cert-tofu "first_use" event; the only writer of a pin is the explicit
  accept_cert_fingerprint command.
- Frontend: a global cert-tofu listener (active during the connect page's health
  checks, before any WS connect) surfaces an SSH-style first-use confirmation
  modal. On accept the fingerprint is pinned and the server re-checked; nothing
  is sent to an unconfirmed host.

Closes security-scan F4 (http proxy) and F8 (ws proxy). Verified: client
typecheck/lint/format clean, full unit suite 3311/3311 green (incl. new ws
first-use routing + modal tests). Rust compiles in CI (cargo clippy) per the
client CLAUDE.md; pure tofu logic covered by #[cfg(test)] unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-23 12:47:30 +02:00
co-authored by Claude Opus 4.8
parent 92dae342e0
commit 1485e13f9a
14 changed files with 701 additions and 566 deletions
+41 -142
View File
@@ -9,10 +9,12 @@
// host. The webview fetches http://127.0.0.1:{port}/api/v1/... and the proxy
// opens a TLS connection to the real server, enforcing the same TOFU
// (Trust On First Use) fingerprint pinning as ws_proxy:
// - Unknown host → accept, persist the fingerprint, emit `cert-tofu`
// (status "trusted_first_use") so the UI can show the banner. HTTP is the
// FIRST TLS contact with a server (login precedes the WS connect), so this
// proxy — not ws_proxy — usually establishes the pin.
// - Unknown host → REJECT (502) and emit `cert-tofu` (status "first_use") so
// the UI prompts the user to confirm the fingerprint. Nothing is pinned or
// forwarded until the user explicitly accepts (accept_cert_fingerprint), so
// no credential is ever sent to an unconfirmed host. HTTP is the FIRST TLS
// contact with a server (login precedes the WS connect), so this proxy
// usually surfaces the first-use prompt. (F4/F8)
// - Pinned host → fingerprint must match or the connection is refused and a
// `cert-tofu` mismatch event fires (CertMismatchModal flow).
//
@@ -27,21 +29,17 @@
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
use log::{debug, error, info, warn};
use ring::digest::{digest, SHA256};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Runtime};
use tauri_plugin_store::StoreExt;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use crate::constants::CERTS_STORE;
use crate::livekit_proxy::cert_store_key;
use crate::tofu::{self, TofuOutcome};
/// Tauri-managed state: one running tunnel per remote host.
pub struct HttpProxyState {
@@ -139,132 +137,10 @@ pub async fn stop_http_proxy(
}
// ---------------------------------------------------------------------------
// TOFU verification (mirrors ws_proxy semantics; shared cert store)
// TOFU verification lives in the shared `tofu` module (crate::tofu):
// CaptureVerifier, cert_store_key, evaluate/decide, and the mismatch message.
// ---------------------------------------------------------------------------
/// Fingerprint captured during the TLS handshake.
type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
/// Accepts the handshake while recording the leaf certificate's SHA-256
/// fingerprint; the TOFU decision happens immediately after the handshake,
/// before any request bytes are forwarded.
#[derive(Debug)]
struct CaptureVerifier {
captured: CapturedFingerprint,
}
impl CaptureVerifier {
fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp)
}
}
impl rustls::client::danger::ServerCertVerifier for CaptureVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(hex);
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// TOFU decision for `host` (cert-store key, i.e. without a default :443):
/// first use stores the pin, match passes, mismatch fails. Same store, same
/// save-rollback behavior, and same event payloads as ws_proxy::tofu_check.
fn tofu_check<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<String, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let stored = store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
});
match stored {
None => {
let old_value = store.get(host);
store.set(host, Value::String(fingerprint.to_string()));
if let Err(e) = store.save() {
match old_value {
Some(v) => {
store.set(host, v);
}
None => {
let _ = store.delete(host);
}
}
return Err(format!("failed to persist cert fingerprint: {e}"));
}
Ok("trusted_first_use".to_string())
}
Some(ref stored_fp) if stored_fp == fingerprint => Ok("trusted".to_string()),
Some(stored_fp) => Err(format!(
"Certificate fingerprint changed for {host}.\n\
Stored: {stored_fp}\n\
Current: {fingerprint}\n\
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
Use accept_cert_fingerprint to trust the new certificate."
)),
}
}
// ---------------------------------------------------------------------------
// Proxy internals
// ---------------------------------------------------------------------------
@@ -400,7 +276,7 @@ async fn handle_connection<R: Runtime>(
let modified = rewrite_request_headers(&buf, remote_host);
// ── 2. TLS connect + TOFU check ──────────────────────────────────────
let (verifier, captured_fp) = CaptureVerifier::new();
let (verifier, captured_fp) = tofu::CaptureVerifier::new();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
@@ -437,22 +313,44 @@ async fn handle_connection<R: Runtime>(
return Err("TLS handshake completed but no certificate fingerprint was captured".into());
}
let store_key = cert_store_key(remote_host);
match tofu_check(&app, &store_key, &fingerprint) {
Ok(status) => {
if status == "trusted_first_use" {
info!("[http_proxy] TOFU first-use pin for {}", store_key);
}
let store_key = tofu::cert_store_key(remote_host);
match tofu::evaluate(&app, &store_key, &fingerprint)? {
TofuOutcome::Trusted => {
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": store_key,
"fingerprint": fingerprint,
"status": status,
"status": "trusted",
}),
);
}
Err(mismatch_msg) => {
// F4/F8: a first-use cert is NOT silently pinned or forwarded to. Reject
// the request (502) and surface the fingerprint so the user can confirm
// it (accept_cert_fingerprint) before any credential-bearing request is
// sent. The connect page's health check triggers this before login.
TofuOutcome::FirstUse => {
info!("[http_proxy] first-use cert for {} — awaiting user confirmation", store_key);
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": store_key,
"fingerprint": fingerprint,
"status": "first_use",
}),
);
let _ = local
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
)
.await;
return Err(format!(
"certificate for {store_key} is not yet trusted; confirm the fingerprint to continue"
)
.into());
}
TofuOutcome::Mismatch { stored } => {
let mismatch_msg = tofu::mismatch_message(&store_key, &stored, &fingerprint);
warn!(
"[http_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch",
store_key
@@ -464,6 +362,7 @@ async fn handle_connection<R: Runtime>(
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
"storedFingerprint": stored,
}),
);
// Give the local fetch a clean HTTP failure instead of a reset.
+1
View File
@@ -4,6 +4,7 @@ mod credentials;
mod http_proxy;
mod livekit_proxy;
mod ptt;
mod tofu;
mod tray;
mod update_commands;
mod ws_proxy;
@@ -26,13 +26,10 @@
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
use log::{debug, error, info, warn};
use ring::digest::{digest, SHA256};
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use serde_json::Value;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -65,118 +62,16 @@ impl LiveKitProxyState {
}
// ---------------------------------------------------------------------------
// TLS certificate verifier — pinned fingerprint check
// TLS verification & cert-store helpers live in the shared `tofu` module
// (crate::tofu): PinnedVerifier, cert_store_key, load_stored_fingerprint.
// ---------------------------------------------------------------------------
use crate::constants::CERTS_STORE;
/// 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)]
pub(crate) struct PinnedVerifier {
/// Expected SHA-256 colon-hex fingerprint (e.g. "aa:bb:cc:...").
expected_fingerprint: String,
}
impl PinnedVerifier {
pub(crate) fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint }
}
}
impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if hex == self.expected_fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"certificate fingerprint mismatch: expected {}, got {}",
self.expected_fingerprint, hex
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
use crate::tofu;
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// 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.
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.
pub(crate) fn load_stored_fingerprint<R: Runtime>(
app: &tauri::AppHandle<R>,
host: &str,
) -> Result<Option<String>, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
Ok(store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
}))
}
/// Start a local TCP proxy that tunnels LiveKit signal connections to the
/// remote OwnCord server over TLS, pinning the certificate to the fingerprint
/// already trusted via ws_proxy's TOFU handshake.
@@ -221,8 +116,8 @@ pub async fn start_livekit_proxy<R: Runtime>(
// have connected first (establishing the TOFU trust), so the fingerprint
// should already be stored. If not, reject — we refuse to connect without
// a pinned cert.
let store_key = cert_store_key(&remote_host);
let fingerprint = load_stored_fingerprint(&app, &store_key)?
let store_key = tofu::cert_store_key(&remote_host);
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
.ok_or_else(|| format!(
"no trusted certificate fingerprint for {remote_host}. \
Connect via WebSocket first to establish TOFU trust."
@@ -384,7 +279,7 @@ async fn handle_connection(
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(
PinnedVerifier::new(pinned_fingerprint.to_string()),
tofu::PinnedVerifier::new(pinned_fingerprint.to_string()),
))
.with_no_client_auth();
@@ -426,36 +321,4 @@ async fn handle_connection(
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_store_key_strips_default_port() {
assert_eq!(cert_store_key("example.com:443"), "example.com");
}
#[test]
fn cert_store_key_keeps_non_default_port() {
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
}
#[test]
fn cert_store_key_no_port() {
assert_eq!(cert_store_key("example.com"), "example.com");
}
#[test]
fn cert_store_key_ipv4_default_port() {
assert_eq!(cert_store_key("192.168.1.1:443"), "192.168.1.1");
}
#[test]
fn cert_store_key_ipv4_custom_port() {
assert_eq!(cert_store_key("192.168.1.1:7880"), "192.168.1.1:7880");
}
}
// cert_store_key is covered by unit tests in the shared `tofu` module.
+314
View File
@@ -0,0 +1,314 @@
// Shared TLS Trust-On-First-Use (TOFU) machinery for the http / ws / livekit
// proxies. Self-hosted servers use self-signed certs, so we pin the leaf cert's
// SHA-256 fingerprint on first use — like SSH's known_hosts.
//
// F4/F8: pinning is now EXPLICIT. A first-use certificate is never silently
// trusted or forwarded to. The proxies capture the fingerprint during the
// handshake, then reject the connection and surface the fingerprint so the user
// can confirm it (via `accept_cert_fingerprint`) before any credential-bearing
// request is sent. `decide` is a pure function with no persistence side effects;
// the only writer of a pin is the explicit `accept_cert_fingerprint` command.
use ring::digest::{digest, SHA256};
use serde_json::Value;
use std::sync::Arc;
use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt;
use crate::constants::CERTS_STORE;
/// Shared fingerprint captured during the TLS handshake.
pub(crate) type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
/// Format a DER-encoded certificate's SHA-256 as lowercase colon-hex
/// ("aa:bb:cc:..."), the canonical pin format used across the cert store.
pub(crate) fn fingerprint_hex(cert_der: &[u8]) -> String {
digest(&SHA256, cert_der)
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
// ── shared rustls signature-verification boilerplate ────────────────────────
// Identical across every verifier; single-homed here so the three proxies don't
// each re-implement it.
pub(crate) fn verify_tls12(
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
pub(crate) fn verify_tls13(
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
pub(crate) fn default_verify_schemes() -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
// ── verifiers ───────────────────────────────────────────────────────────────
/// A rustls verifier that ACCEPTS any leaf cert but records its fingerprint for
/// the post-handshake TOFU decision. Used by the http and ws proxies. Accepting
/// here is safe only because `evaluate` + the caller gate on the pin afterward.
#[derive(Debug)]
pub(crate) struct CaptureVerifier {
captured: CapturedFingerprint,
}
impl CaptureVerifier {
pub(crate) fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp)
}
}
impl rustls::client::danger::ServerCertVerifier for CaptureVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(fingerprint_hex(end_entity.as_ref()));
}
// Accept — the TOFU decision happens after the handshake, before any
// request bytes are forwarded.
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
verify_tls12(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
verify_tls13(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
default_verify_schemes()
}
}
/// A rustls verifier that requires the leaf cert to match a pinned fingerprint,
/// failing the handshake itself on mismatch. Used by the livekit proxy, which
/// refuses to start unless a pin already exists (no TOFU establishment).
#[derive(Debug)]
pub(crate) struct PinnedVerifier {
expected_fingerprint: String,
}
impl PinnedVerifier {
pub(crate) fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint }
}
}
impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let hex = fingerprint_hex(end_entity.as_ref());
if hex == self.expected_fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"certificate fingerprint mismatch: expected {}, got {}",
self.expected_fingerprint, hex
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
verify_tls12(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
verify_tls13(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
default_verify_schemes()
}
}
// ── store keys ──────────────────────────────────────────────────────────────
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
pub(crate) fn cert_store_key(host: &str) -> String {
host.strip_suffix(":443").unwrap_or(host).to_string()
}
/// Extract the host (with any non-default port) from a `wss://` URL.
pub(crate) fn extract_host(url: &str) -> String {
cert_store_key(
url.strip_prefix("wss://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or(url),
)
}
/// Load the stored pin for `host` from the Tauri cert store.
pub(crate) fn load_stored_fingerprint<R: Runtime>(
app: &AppHandle<R>,
host: &str,
) -> Result<Option<String>, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
Ok(store.get(host).and_then(|v| match v {
Value::String(s) => Some(s),
_ => None,
}))
}
// ── the TOFU decision (pure) ────────────────────────────────────────────────
/// The trust decision for an observed fingerprint given the stored pin.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum TofuOutcome {
/// A pin exists and matches — proceed.
Trusted,
/// No pin exists — do NOT trust or forward; ask the user to confirm.
FirstUse,
/// A pin exists but differs — reject; possible MITM or cert rotation.
Mismatch { stored: String },
}
/// Pure trust decision. No I/O, no persistence — this is the whole point of the
/// F4/F8 fix: deciding never writes a pin.
pub(crate) fn decide(stored: Option<String>, current: &str) -> TofuOutcome {
match stored {
None => TofuOutcome::FirstUse,
Some(s) if s == current => TofuOutcome::Trusted,
Some(s) => TofuOutcome::Mismatch { stored: s },
}
}
/// Load the stored pin and decide. Never persists.
pub(crate) fn evaluate<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<TofuOutcome, String> {
let stored = load_stored_fingerprint(app, host)?;
Ok(decide(stored, fingerprint))
}
/// The human-readable mismatch message. The frontend parses `Stored:` out of it,
/// so keep this exact shape stable.
pub(crate) fn mismatch_message(host: &str, stored: &str, current: &str) -> String {
format!(
"Certificate fingerprint changed for {host}.\n\
Stored: {stored}\n\
Current: {current}\n\
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
Use accept_cert_fingerprint to trust the new certificate."
)
}
// ---------------------------------------------------------------------------
// Tests (pure logic only — no Tauri runtime required)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decide_first_use_when_no_pin() {
assert_eq!(decide(None, "aa:bb"), TofuOutcome::FirstUse);
}
#[test]
fn decide_trusted_when_pin_matches() {
assert_eq!(decide(Some("aa:bb".into()), "aa:bb"), TofuOutcome::Trusted);
}
#[test]
fn decide_mismatch_when_pin_differs() {
assert_eq!(
decide(Some("aa:bb".into()), "cc:dd"),
TofuOutcome::Mismatch { stored: "aa:bb".into() }
);
}
#[test]
fn cert_store_key_strips_default_443_only() {
assert_eq!(cert_store_key("example.com:443"), "example.com");
assert_eq!(cert_store_key("example.com"), "example.com");
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
}
#[test]
fn extract_host_variants() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
assert_eq!(extract_host("wss://example.com:443/chat"), "example.com");
assert_eq!(extract_host("wss://example.com"), "example.com");
assert_eq!(extract_host("example.com/path"), "example.com");
assert_eq!(extract_host(""), "");
}
#[test]
fn fingerprint_hex_of_empty_is_known_sha256() {
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
assert_eq!(
fingerprint_hex(b""),
"e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55"
);
}
}
+38 -208
View File
@@ -1,14 +1,17 @@
// WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection.
// JS sends/receives messages via Tauri events instead of native WebSocket.
//
// Implements TOFU (Trust On First Use) certificate pinning:
// - On first connect to a host, the cert SHA-256 fingerprint is stored.
// - On subsequent connects, the fingerprint is compared with the stored value.
// - If the fingerprint changes, the connection is rejected (potential MitM).
// Implements TOFU (Trust On First Use) certificate pinning via the shared
// `tofu` module:
// - The cert SHA-256 fingerprint is captured during the handshake.
// - On a known host it must match the stored pin, or the connection is rejected.
// - On first use (no pin yet) the connection is rejected and a `cert-tofu`
// "first_use" event is emitted so the user can confirm the fingerprint. F4/F8:
// the proxy never silently pins or forwards to an unconfirmed host — the only
// writer of a pin is the explicit `accept_cert_fingerprint` command.
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use ring::digest::{digest, SHA256};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
@@ -22,6 +25,7 @@ use tokio_tungstenite::tungstenite::Message;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
use crate::constants::CERTS_STORE;
use crate::tofu::{self, TofuOutcome};
/// Sender half kept in Tauri state so `ws_send` can push messages.
/// `tx` is wrapped in `Arc` so the monitoring task can clone a reference
@@ -38,157 +42,6 @@ impl WsState {
}
}
/// Shared fingerprint captured during TLS handshake.
type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
/// TOFU certificate verifier that captures the server cert fingerprint
/// during the TLS handshake. Still accepts self-signed certs (required
/// for self-hosted servers), but records the fingerprint for comparison
/// with the stored value after the connection is established.
#[derive(Debug)]
struct TofuVerifier {
captured: CapturedFingerprint,
}
impl TofuVerifier {
fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp)
}
}
impl rustls::client::danger::ServerCertVerifier for TofuVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
// Compute SHA-256 fingerprint of the DER-encoded leaf certificate.
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(hex);
}
// Accept the cert — TOFU check happens after the handshake completes.
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
/// Extract the host (with port) from a wss:// URL.
fn extract_host(url: &str) -> String {
url.strip_prefix("wss://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or(url)
.to_string()
}
/// Perform TOFU fingerprint check against the Tauri cert store.
/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch.
fn tofu_check<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<String, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let stored = store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
});
match stored {
None => {
// First use — store the fingerprint.
// Capture old value before mutating (None here, but consistent pattern).
let old_value = store.get(host);
store.set(host, Value::String(fingerprint.to_string()));
if let Err(e) = store.save() {
// Restore previous in-memory state: put back old value or delete
// if there was none, keeping in-memory consistent with on-disk.
match old_value {
Some(v) => { store.set(host, v); }
None => { let _ = store.delete(host); }
}
return Err(format!("failed to persist cert fingerprint: {e}"));
}
Ok("trusted_first_use".to_string())
}
Some(ref stored_fp) if stored_fp == fingerprint => {
Ok("trusted".to_string())
}
Some(stored_fp) => {
Err(format!(
"Certificate fingerprint changed for {host}.\n\
Stored: {stored_fp}\n\
Current: {fingerprint}\n\
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
Use accept_cert_fingerprint to trust the new certificate."
))
}
}
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
fn emit_ws_state<R: Runtime>(app: &AppHandle<R>, state: &str) {
let _ = app.emit("ws-state", state);
@@ -229,8 +82,9 @@ pub async fn ws_connect<R: Runtime>(
emit_ws_state(&app, "connecting");
// Create TOFU verifier that captures the cert fingerprint during handshake.
let (verifier, captured_fp) = TofuVerifier::new();
// Capture the cert fingerprint during the handshake; the TOFU decision runs
// afterward, before the socket is used.
let (verifier, captured_fp) = tofu::CaptureVerifier::new();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
@@ -261,7 +115,7 @@ pub async fn ws_connect<R: Runtime>(
debug!("[ws_proxy] WebSocket handshake complete");
// ── TOFU check ───────────────────────────────────────────────────────
let host = extract_host(&url);
let host = tofu::extract_host(&url);
let fingerprint = captured_fp
.lock()
.map_err(|e| format!("failed to read captured fingerprint: {e}"))?
@@ -272,26 +126,41 @@ pub async fn ws_connect<R: Runtime>(
return Err("TLS handshake completed but no certificate fingerprint was captured".into());
}
match tofu_check(&app, &host, &fingerprint) {
Ok(status) => {
info!("[ws_proxy] TOFU check passed for {}: {}", host, status);
match tofu::evaluate(&app, &host, &fingerprint)? {
TofuOutcome::Trusted => {
info!("[ws_proxy] TOFU check passed for {}", host);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": status,
"status": "trusted",
}));
}
Err(mismatch_msg) => {
TofuOutcome::FirstUse => {
info!("[ws_proxy] first-use cert for {} — awaiting user confirmation", host);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "first_use",
}));
// Do not open the socket: the user must confirm the fingerprint
// (accept_cert_fingerprint) before anything is sent over it.
return Err(format!(
"certificate for {host} is not yet trusted; confirm the fingerprint to continue"
));
}
TofuOutcome::Mismatch { stored } => {
let msg = tofu::mismatch_message(&host, &stored, &fingerprint);
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
debug!("[ws_proxy] TOFU detail: {}", mismatch_msg);
debug!("[ws_proxy] TOFU detail: {}", msg);
emit_cert_tofu(&app, serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
"message": msg,
"storedFingerprint": stored,
}));
// Reject the connection — do not proceed.
return Err(mismatch_msg);
return Err(msg);
}
}
// ── End TOFU check ───────────────────────────────────────────────────
@@ -413,8 +282,8 @@ pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), Strin
Ok(())
}
/// Accept a changed certificate fingerprint for a host.
/// Call this after the user acknowledges a cert-mismatch warning.
/// Accept a certificate fingerprint for a host — the ONLY path that writes a pin.
/// Called after the user acknowledges a first-use or cert-mismatch prompt.
#[tauri::command]
pub fn accept_cert_fingerprint<R: Runtime>(
app: AppHandle<R>,
@@ -458,42 +327,3 @@ pub fn accept_cert_fingerprint<R: Runtime>(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_host_basic_wss_url() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
}
#[test]
fn extract_host_with_port() {
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
}
#[test]
fn extract_host_no_path() {
assert_eq!(extract_host("wss://example.com"), "example.com");
}
#[test]
fn extract_host_no_scheme() {
assert_eq!(extract_host("example.com/path"), "example.com");
}
#[test]
fn extract_host_empty() {
assert_eq!(extract_host(""), "");
}
#[test]
fn extract_host_with_port_and_deep_path() {
assert_eq!(extract_host("wss://myhost:9443/api/v1/ws"), "myhost:9443");
}
}
@@ -108,6 +108,99 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
return { mount, destroy };
}
export interface CertFirstUseModalOptions {
readonly host: string;
readonly fingerprint: string;
readonly onAccept: () => void;
readonly onReject: () => void;
}
/**
* createCertFirstUseModal — shown on the FIRST connection to a server, when no
* certificate is pinned yet (F4/F8). The proxy refuses to send anything until
* the user confirms this fingerprint, so an on-path attacker at first contact
* cannot silently capture credentials. Mirrors an SSH known-hosts prompt.
*/
export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent {
const { host, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "New Server Certificate");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
appendChildren(header, title, closeBtn);
const body = createElement("div", { class: "modal-body" });
const warning = createElement("div", { class: "cert-warning" });
warning.appendChild(createIcon("triangle-alert", 24));
const certTitle = createElement("div", { class: "cert-title" });
setText(certTitle, "Confirm the certificate fingerprint");
const desc = createElement("div", { class: "cert-desc" });
setText(
desc,
"This is the first connection to this server, so its certificate is not " +
"yet trusted. Verify the fingerprint below out-of-band (e.g. with the " +
"server operator) before trusting it — on an untrusted network an " +
"attacker could present a fake certificate.",
);
const details = createElement("div", { class: "cert-details" });
appendChildren(
details,
buildRow("Host", host, false),
buildRow("Fingerprint", fingerprint, true),
);
appendChildren(body, warning, certTitle, desc, details);
const footer = createElement("div", { class: "modal-footer" });
const rejectBtn = createElement("button", { class: "btn-ghost", type: "button" });
setText(rejectBtn, "Cancel");
rejectBtn.addEventListener("click", onReject, { signal: ac.signal });
const acceptBtn = createElement("button", { class: "btn-danger", type: "button" });
setText(acceptBtn, "Trust This Certificate");
acceptBtn.addEventListener("click", onAccept, { signal: ac.signal });
appendChildren(footer, rejectBtn, acceptBtn);
appendChildren(modal, header, body, footer);
overlay.appendChild(modal);
overlay.addEventListener(
"click",
(e) => {
if (e.target === overlay) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
}
function destroy(): void {
ac.abort();
if (overlay !== null) {
overlay.remove();
overlay = null;
}
}
return { mount, destroy };
}
function buildRow(label: string, value: string, isFingerprint: boolean): HTMLDivElement {
const row = createElement("div", { class: "cert-row" });
const labelEl = createElement("span", { class: "cert-label" });
+73 -41
View File
@@ -62,11 +62,15 @@ export type WsListener<T extends ServerMessage["type"]> = (
id?: string,
) => void;
/** TOFU certificate event emitted by the Rust WS proxy. */
/** TOFU certificate event emitted by the Rust proxies (http / ws).
* - "first_use": no pin yet — the proxy REJECTED the connection; the user must
* confirm this fingerprint (acceptCertFingerprint) before anything is sent.
* - "trusted": pin matches — proceed.
* - "mismatch": pin differs — reject (possible MITM or cert rotation). */
export interface CertTofuEvent {
readonly host: string;
readonly fingerprint: string;
readonly status: "trusted_first_use" | "trusted" | "mismatch";
readonly status: "first_use" | "trusted" | "mismatch";
readonly message?: string;
readonly storedFingerprint?: string;
}
@@ -79,7 +83,7 @@ export function parseStoredFingerprint(message?: string): string | undefined {
}
export type CertMismatchListener = (event: CertTofuEvent) => void;
export type CertFirstTrustListener = (event: CertTofuEvent) => void;
export type CertFirstUseListener = (event: CertTofuEvent) => void;
export interface WsClientConfig {
readonly host: string;
@@ -129,8 +133,13 @@ export function createWsClient() {
// TOFU cert mismatch listeners
const certMismatchListeners = new Set<CertMismatchListener>();
// TOFU first-trust listeners (BUG-133)
const certFirstTrustListeners = new Set<CertFirstTrustListener>();
// TOFU first-use confirmation listeners (F4/F8)
const certFirstUseListeners = new Set<CertFirstUseListener>();
// Global cert-tofu Tauri listener unsub (registered once via startCertListener,
// active for the whole app lifetime so first-use/mismatch events are received
// during the connect page's health checks — before any WS connect).
let certListenerUnsub: (() => void) | null = null;
function setState(newState: ConnectionState): void {
if (state !== newState) {
@@ -299,6 +308,38 @@ export function createWsClient() {
}
}
// Route a cert-tofu event (from the http or ws proxy) to the right listeners.
// Registered globally via startCertListener so first-use/mismatch events are
// received during the connect page's health checks, before any WS connect.
function handleCertTofu(raw: CertTofuEvent): void {
log.info("TOFU cert event", { host: raw.host, status: raw.status });
if (raw.status === "first_use") {
log.warn("TOFU: first-use certificate — awaiting user confirmation", {
host: raw.host,
fingerprint: raw.fingerprint,
});
for (const listener of certFirstUseListeners) {
listener(raw);
}
} else if (raw.status === "mismatch") {
const evt: CertTofuEvent = {
...raw,
storedFingerprint: raw.storedFingerprint ?? parseStoredFingerprint(raw.message),
};
log.error("Certificate fingerprint mismatch!", {
host: evt.host,
fingerprint: evt.fingerprint,
storedFingerprint: evt.storedFingerprint,
});
certMismatchBlock = true;
setState("disconnected");
for (const listener of certMismatchListeners) {
listener(evt);
}
}
// "trusted" → no action
}
async function setupEventListeners(): Promise<void> {
if (tauriListen === null) return;
@@ -356,38 +397,15 @@ export function createWsClient() {
});
eventUnsubs.push(unsubErr);
// TOFU certificate events
const unsubCert = await tauriListen("cert-tofu", (e) => {
if (gen !== wsGeneration) return;
const raw = e.payload as CertTofuEvent;
log.info("TOFU cert event", { host: raw.host, status: raw.status });
if (raw.status === "trusted_first_use") {
log.warn("TOFU: first-use certificate trust", {
host: raw.host,
fingerprint: raw.fingerprint,
});
for (const listener of certFirstTrustListeners) {
listener(raw);
}
} else if (raw.status === "mismatch") {
const evt: CertTofuEvent = {
...raw,
storedFingerprint: parseStoredFingerprint(raw.message),
};
log.error("Certificate fingerprint mismatch!", {
host: evt.host,
fingerprint: evt.fingerprint,
storedFingerprint: evt.storedFingerprint,
});
certMismatchBlock = true;
setState("disconnected");
for (const listener of certMismatchListeners) {
listener(evt);
}
}
});
eventUnsubs.push(unsubCert);
// Register the global cert-tofu listener on first connect (idempotent).
// startCertListener() registers the same listener at app bootstrap so
// first-use/mismatch events are also caught during the connect page's health
// checks, before any WS connection exists.
if (certListenerUnsub === null) {
certListenerUnsub = await tauriListen("cert-tofu", (e) => {
handleCertTofu(e.payload as CertTofuEvent);
});
}
}
function cleanupEventListeners(): void {
@@ -556,10 +574,24 @@ export function createWsClient() {
return () => sendFailureListeners.delete(listener);
},
/** Register a listener for TOFU first-trust events (BUG-133). */
onCertFirstTrust(listener: CertFirstTrustListener): () => void {
certFirstTrustListeners.add(listener);
return () => certFirstTrustListeners.delete(listener);
/**
* Register the global cert-tofu event listener. Idempotent. Call once at app
* bootstrap (before the connect page's health checks) so first-use and
* mismatch events are received even before a WS connection exists.
*/
async startCertListener(): Promise<void> {
if (certListenerUnsub !== null) return;
await ensureTauriApis();
if (tauriListen === null) return;
certListenerUnsub = await tauriListen("cert-tofu", (e) => {
handleCertTofu(e.payload as CertTofuEvent);
});
},
/** Register a listener for TOFU first-use confirmation events (F4/F8). */
onCertFirstUse(listener: CertFirstUseListener): () => void {
certFirstUseListeners.add(listener);
return () => certFirstUseListeners.delete(listener);
},
/** Register a listener for TOFU certificate mismatch events. */
+49 -27
View File
@@ -26,7 +26,7 @@ import { createLogger } from "@lib/logger";
import { initLogPersistence, flushLogs } from "@lib/logPersistence";
import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials";
import { initWindowState } from "@lib/window-state";
import { createCertMismatchModal } from "@components/CertMismatchModal";
import { createCertMismatchModal, createCertFirstUseModal } from "@components/CertMismatchModal";
import { createProfileManager, createTauriBackend } from "@lib/profiles";
import type { CertTofuEvent } from "@lib/ws";
@@ -105,37 +105,49 @@ let dispatcherCleanup: (() => void) | null = null;
let connectedOverlay: ConnectedOverlayControl | null = null;
let lastConnectHost = "";
let lastConnectToken = "";
// Re-run the connect page's health checks (set while the connect page is
// mounted, cleared otherwise) — refreshes a server's status after its
// certificate is trusted for the first time.
let rerunConnectHealth: (() => void) | null = null;
// Certificate first-trust notification (BUG-133).
// Show a brief banner so the user is aware a new server cert was pinned.
ws.onCertFirstTrust((evt: CertTofuEvent) => {
log.warn("TOFU: first-use certificate pinned", {
// Shared guard so the first-use and mismatch cert modals never stack.
let certModalActive = false;
// First-use certificate confirmation (F4/F8). The Rust proxy REJECTS the first
// connection to a server until the user confirms its fingerprint, so no
// credential is ever sent to an unconfirmed host. This fires during the connect
// page's health check (the first TLS contact), before login.
ws.onCertFirstUse((evt: CertTofuEvent) => {
if (certModalActive) return;
certModalActive = true;
const modal = createCertFirstUseModal({
host: evt.host,
fingerprint: evt.fingerprint,
onAccept: () => {
modal.destroy?.();
certModalActive = false;
void (async () => {
try {
await ws.acceptCertFingerprint(evt.host, evt.fingerprint);
// Refresh server health so the now-trusted host becomes reachable,
// and resume a pending connect if one was in flight.
rerunConnectHealth?.();
if (lastConnectHost && lastConnectToken) {
ws.connect({ host: lastConnectHost, token: lastConnectToken });
}
} catch (err) {
log.error("Failed to trust first-use certificate", err);
}
})();
},
onReject: () => {
modal.destroy?.();
certModalActive = false;
},
});
const banner = document.createElement("div");
Object.assign(banner.style, {
position: "fixed",
top: "12px",
left: "50%",
transform: "translateX(-50%)",
background: "#2d5a27",
color: "#e0e0e0",
padding: "10px 20px",
borderRadius: "8px",
fontSize: "13px",
zIndex: "10000",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
cursor: "default",
});
banner.textContent = `New server certificate trusted for ${evt.host}`;
banner.title = `SHA-256: ${evt.fingerprint}`;
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 8000);
modal.mount(document.body);
});
// Certificate mismatch modal handler
let certModalActive = false;
ws.onCertMismatch((evt: CertTofuEvent) => {
if (certModalActive) return;
certModalActive = true;
@@ -169,6 +181,10 @@ ws.onCertMismatch((evt: CertTofuEvent) => {
modal.mount(document.body);
});
// Register the global cert-tofu listener now so first-use / mismatch prompts
// are received during the connect page's health checks, before any WS connect.
void ws.startCertListener();
// Current page component reference for cleanup
let currentPage: { destroy?(): void } | null = null;
@@ -224,6 +240,8 @@ function renderPage(pageId: "connect" | "main"): void {
currentPage?.destroy?.();
currentPage = null;
appEl!.textContent = "";
// Only valid while the connect page is mounted (re-set in its render branch).
rerunConnectHealth = null;
// Shared helper for post-auth WS connect + overlay flow
function wirePostAuth(
@@ -433,6 +451,10 @@ function renderPage(pageId: "connect" | "main"): void {
},
};
// Expose a health-refresh hook so trusting a first-use certificate can
// re-check the now-reachable server without a full page navigation.
rerunConnectHealth = () => runHealthChecks(connectPage, getProfileList());
// Load saved profiles and kick off health checks
void (async () => {
try {
+5 -1
View File
@@ -78,7 +78,11 @@ export function createMockWsClient() {
return () => sendFailureListeners.delete(listener);
},
onCertFirstTrust(): () => void {
async startCertListener(): Promise<void> {
// no-op in mock
},
onCertFirstUse(): () => void {
return () => {};
},
@@ -67,7 +67,9 @@ function createMockWsClient(): MockWsClient {
return () => {};
},
onCertFirstTrust(): () => void {
async startCertListener(): Promise<void> {},
onCertFirstUse(): () => void {
return () => {};
},
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { createCertFirstUseModal } from "../../src/components/CertMismatchModal";
describe("createCertFirstUseModal (F4/F8)", () => {
it("renders the host + fingerprint and wires accept/reject", () => {
const onAccept = vi.fn();
const onReject = vi.fn();
const modal = createCertFirstUseModal({
host: "example.com:8443",
fingerprint: "aa:bb:cc:dd:ee:ff",
onAccept,
onReject,
});
const container = document.createElement("div");
modal.mount(container);
const text = container.textContent ?? "";
expect(text).toContain("example.com:8443");
expect(text).toContain("aa:bb:cc:dd:ee:ff");
const buttons = Array.from(container.querySelectorAll("button"));
const trustBtn = buttons.find((b) => b.textContent === "Trust This Certificate");
const cancelBtn = buttons.find((b) => b.textContent === "Cancel");
expect(trustBtn).toBeTruthy();
expect(cancelBtn).toBeTruthy();
trustBtn!.click();
expect(onAccept).toHaveBeenCalledTimes(1);
cancelBtn!.click();
expect(onReject).toHaveBeenCalledTimes(1);
modal.destroy?.();
expect(container.querySelector(".modal-overlay")).toBeNull();
});
});
@@ -65,7 +65,8 @@ function createMockWs() {
sendFailureListeners.add(listener);
return () => sendFailureListeners.delete(listener);
},
onCertFirstTrust: vi.fn(() => () => {}),
startCertListener: vi.fn(async () => {}),
onCertFirstUse: vi.fn(() => () => {}),
onCertMismatch: vi.fn(() => () => {}),
acceptCertFingerprint: vi.fn(async () => {}),
getState: vi.fn(() => "disconnected" as const),
@@ -27,7 +27,8 @@ function createMockWs(state: "connected" | "disconnected" = "connected"): WsClie
stateListeners.add(listener);
return () => stateListeners.delete(listener);
}),
onCertFirstTrust: vi.fn().mockReturnValue(() => {}),
startCertListener: vi.fn().mockResolvedValue(undefined),
onCertFirstUse: vi.fn().mockReturnValue(() => {}),
onCertMismatch: vi.fn().mockReturnValue(() => {}),
acceptCertFingerprint: vi.fn(),
getState: vi.fn(() => currentState),
+36
View File
@@ -629,6 +629,42 @@ describe("cert mismatch blocking", () => {
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything());
});
it("routes first_use cert events to onCertFirstUse, not onCertMismatch (F4/F8)", async () => {
const firstUse: unknown[] = [];
const mismatch: unknown[] = [];
client.onCertFirstUse((e) => firstUse.push(e));
client.onCertMismatch((e) => mismatch.push(e));
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("cert-tofu", {
host: "localhost:8443",
fingerprint: "sha256:NEW",
status: "first_use",
});
expect(firstUse).toHaveLength(1);
expect(mismatch).toHaveLength(0);
});
it("startCertListener catches cert events before any WS connect (connect-page path)", async () => {
const firstUse: unknown[] = [];
client.onCertFirstUse((e) => firstUse.push(e));
// No connect() — main.ts registers the listener at bootstrap so first-use
// fires during the connect page's health check, before login.
await client.startCertListener();
emitTauriEvent("cert-tofu", {
host: "localhost:8443",
fingerprint: "sha256:NEW",
status: "first_use",
});
expect(firstUse).toHaveLength(1);
});
it("should not schedule reconnect when certMismatchBlock is true", async () => {
const mismatchEvents: unknown[] = [];
client.onCertMismatch((evt) => mismatchEvents.push(evt));