mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user