fix(updater): make client auto-update work end-to-end and Linux server self-update verifiable

- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
  server-echoed platforms key matches the updater plugin's
  {os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
  the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
  HostScopedVerifier; the GitHub installer download validates against web PKI
  instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
  diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
  per-arch AppImage) and returns 204 for targets without a published updater
  artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
  top-level pair kept pointing at the Windows binary so deployed servers still
  verify); VerifyReleaseManifest resolves the entry matching the downloaded
  asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
  signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
  merge the two ptt tests that raced on the global PTT_VKEY atomic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-23 18:38:22 +02:00
co-authored by Claude Fable 5
parent 3b1b6deb46
commit f3a89e0e09
13 changed files with 693 additions and 152 deletions
+1
View File
@@ -2955,6 +2955,7 @@ dependencies = [
"tokio-rustls",
"tokio-tungstenite",
"url",
"webpki-roots 1.0.6",
"windows 0.58.0",
]
+8 -1
View File
@@ -31,7 +31,10 @@ tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-updater = "2"
# Minor-pinned per the plugin's own guidance: configure_client hands it a
# preconfigured rustls ClientConfig, and a 2.x minor bump can change the
# plugin's bundled reqwest/rustls and break that seam at runtime.
tauri-plugin-updater = "2.10"
tauri-plugin-process = "2"
url = "2"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
@@ -39,6 +42,10 @@ futures-util = "0.3.32"
tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] }
tokio-rustls = { version = "0.26", default-features = false }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# Mozilla root bundle for the updater's HostScopedVerifier: non-pinned hosts
# (the GitHub installer download) get web-PKI validation. Already in the
# dependency tree via tokio-tungstenite's rustls-tls-webpki-roots feature.
webpki-roots = "1"
ring = "0.17"
log = "0.4"
env_logger = "0.11"
+9 -10
View File
@@ -479,13 +479,20 @@ pub async fn ptt_listen_for_key() -> i32 {
mod tests {
use super::*;
// PTT_VKEY is a process-global AtomicI32 and cargo runs tests on parallel
// threads, so every mutating assertion lives in this ONE test — splitting
// them across tests makes the get/set assertions racy.
#[test]
fn ptt_set_key_accepts_valid_codes() {
fn ptt_set_key_accepts_valid_codes_and_get_reflects_them() {
assert!(ptt_set_key(0).is_ok());
assert!(ptt_set_key(1).is_ok());
assert!(ptt_set_key(0x20).is_ok()); // Space
assert!(ptt_set_key(0x41).is_ok()); // A
assert!(ptt_set_key(254).is_ok());
ptt_set_key(0x41).unwrap(); // A
assert_eq!(ptt_get_key(), 0x41);
ptt_set_key(0).unwrap();
assert_eq!(ptt_get_key(), 0);
}
#[test]
@@ -517,14 +524,6 @@ mod tests {
assert!(!is_allowed_ptt_capture_vk(0x5B)); // Meta
}
#[test]
fn ptt_get_key_reflects_set_key() {
ptt_set_key(0x41).unwrap();
assert_eq!(ptt_get_key(), 0x41);
ptt_set_key(0).unwrap();
assert_eq!(ptt_get_key(), 0);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_keycode_round_trips_for_common_keys() {
+218
View File
@@ -181,6 +181,103 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
}
}
/// A rustls verifier that applies the pinned-fingerprint check ONLY to the
/// named host and normal web-PKI validation to every other host. Used by the
/// updater, whose single HTTP client talks both to the (possibly self-signed,
/// TOFU-pinned) OwnCord server for update metadata and to GitHub for the
/// installer download — a client-wide pin would reject GitHub's certificate.
#[derive(Debug)]
pub(crate) struct HostScopedVerifier {
pinned_host: String,
pinned: PinnedVerifier,
default: Arc<dyn rustls::client::danger::ServerCertVerifier>,
}
impl HostScopedVerifier {
pub(crate) fn new(pinned_host: String, expected_fingerprint: String) -> Result<Self, String> {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let default = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
Arc::new(rustls::crypto::ring::default_provider()),
)
.build()
.map_err(|e| format!("failed to build web-PKI verifier: {e}"))?;
Ok(Self::with_default(pinned_host, expected_fingerprint, default))
}
/// Seam for tests: inject the verifier used for non-pinned hosts.
fn with_default(
pinned_host: String,
expected_fingerprint: String,
default: Arc<dyn rustls::client::danger::ServerCertVerifier>,
) -> Self {
// url::Url wraps IPv6 hosts in brackets; ServerName renders them bare.
let pinned_host = pinned_host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
Self {
pinned_host,
pinned: PinnedVerifier::new(expected_fingerprint),
default,
}
}
fn is_pinned_host(&self, server_name: &rustls::pki_types::ServerName<'_>) -> bool {
match server_name {
rustls::pki_types::ServerName::DnsName(d) => {
d.as_ref().eq_ignore_ascii_case(&self.pinned_host)
}
rustls::pki_types::ServerName::IpAddress(ip) => {
std::net::IpAddr::from(*ip).to_string() == self.pinned_host
}
_ => false,
}
}
}
impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
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 self.is_pinned_host(server_name) {
self.pinned
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
} else {
self.default
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
}
}
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
@@ -311,4 +408,125 @@ mod tests {
"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"
);
}
// ── HostScopedVerifier ──────────────────────────────────────────────────
/// Stub for the non-pinned-host verifier: records nothing, just returns a
/// fixed verdict so tests can prove which path a connection was routed to.
#[derive(Debug)]
struct StubVerifier {
accept: bool,
}
impl rustls::client::danger::ServerCertVerifier for StubVerifier {
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 self.accept {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General("stub rejected".into()))
}
}
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()
}
}
fn host_scoped(pinned_host: &str, cert_bytes: &[u8], stub_accepts: bool) -> HostScopedVerifier {
HostScopedVerifier::with_default(
pinned_host.to_string(),
fingerprint_hex(cert_bytes),
Arc::new(StubVerifier { accept: stub_accepts }),
)
}
fn verify(
v: &HostScopedVerifier,
host: &str,
cert_bytes: &[u8],
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use rustls::client::danger::ServerCertVerifier;
let cert = rustls::pki_types::CertificateDer::from(cert_bytes.to_vec());
let name = rustls::pki_types::ServerName::try_from(host.to_string()).unwrap();
v.verify_server_cert(
&cert,
&[],
&name,
&[],
rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)),
)
}
#[test]
fn host_scoped_pins_matching_host() {
// Stub rejects, so success proves the PINNED path handled it.
let v = host_scoped("chat.example.com", b"server-cert", false);
assert!(verify(&v, "chat.example.com", b"server-cert").is_ok());
}
#[test]
fn host_scoped_rejects_wrong_cert_on_pinned_host() {
let err = verify(
&host_scoped("chat.example.com", b"server-cert", true),
"chat.example.com",
b"mitm-cert",
)
.unwrap_err();
assert!(err.to_string().contains("fingerprint mismatch"), "{err}");
}
#[test]
fn host_scoped_delegates_other_hosts_to_default() {
// Cert does NOT match the pin; success proves the DEFAULT path handled it.
let v = host_scoped("chat.example.com", b"server-cert", true);
assert!(verify(&v, "github.com", b"github-cert").is_ok());
}
#[test]
fn host_scoped_default_rejection_propagates() {
let err = verify(
&host_scoped("chat.example.com", b"server-cert", false),
"github.com",
b"github-cert",
)
.unwrap_err();
assert!(err.to_string().contains("stub rejected"), "{err}");
}
#[test]
fn host_scoped_host_match_is_case_insensitive() {
let v = host_scoped("Chat.Example.COM", b"server-cert", false);
assert!(verify(&v, "chat.example.com", b"server-cert").is_ok());
}
#[test]
fn host_scoped_matches_ip_pinned_host() {
let v = host_scoped("192.168.1.10", b"server-cert", false);
assert!(verify(&v, "192.168.1.10", b"server-cert").is_ok());
}
}
@@ -3,7 +3,7 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt;
use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier};
use crate::tofu::{cert_store_key, load_stored_fingerprint, HostScopedVerifier};
#[derive(Serialize)]
pub struct UpdateCheckResult {
@@ -36,17 +36,25 @@ fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
Ok(cert_store_key(&raw))
}
/// Build a rustls ClientConfig that validates the server cert against the
/// TOFU-pinned fingerprint. Falls back to system certs if no fingerprint
/// is stored (server uses a real CA cert).
/// Build a rustls ClientConfig for the updater. When a TOFU fingerprint is
/// stored, the pin is enforced for the OwnCord server's host ONLY — the same
/// HTTP client also downloads the installer from GitHub, whose certificate
/// must pass normal web-PKI validation instead (a client-wide pin would
/// reject it and every install would fail).
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
let store_key = extract_host_for_cert_store(server_url)?;
let fingerprint = load_stored_fingerprint(app, &store_key)?;
match fingerprint {
Some(fp) => {
let parsed = url::Url::parse(server_url)
.map_err(|e| format!("failed to parse server URL: {e}"))?;
let host = parsed
.host_str()
.ok_or_else(|| "server URL has no host".to_string())?;
let verifier = HostScopedVerifier::new(host.to_string(), fp)?;
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinnedVerifier::new(fp)))
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth();
Ok(Some(config))
}
@@ -57,6 +65,57 @@ fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::
}
}
/// Tauri updater endpoint on the given server. `{{target}}-{{arch}}-{{bundle_type}}`
/// (expanded by the updater plugin to e.g. "windows-x86_64-nsis") must match
/// the `{os}-{arch}-{installer}` key the plugin looks up FIRST in the response
/// `platforms` map — the server echoes this path segment back as that key.
/// The bundle type matters: a deb-installed client must get 204, not the
/// AppImage archive, or its install step rejects every update.
fn build_update_endpoint(server_url: &str, current_version: &str) -> String {
format!(
"{}/api/v1/client-update/{{{{target}}}}-{{{{arch}}}}-{{{{bundle_type}}}}/{}",
server_url.trim_end_matches('/'),
current_version,
)
}
/// Build an updater wired to the given OwnCord server: dynamic endpoint plus
/// host-scoped TOFU TLS. Shared by check and install so the two paths can
/// never diverge on endpoint format or trust configuration.
fn build_updater(
app: &AppHandle,
server_url: &str,
) -> Result<tauri_plugin_updater::Updater, String> {
validate_server_url(server_url)?;
let current_version = app
.config()
.version
.clone()
.unwrap_or_else(|| "0.0.0".to_string());
let endpoint = build_update_endpoint(server_url, &current_version);
let url: url::Url = endpoint
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// Use TOFU-pinned certificate for self-signed servers, or system certs
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
let tls_config = build_tls_config(app, server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder =
builder.configure_client(move |client| client.use_preconfigured_tls((*config).clone()));
}
builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))
}
/// Validate that a server URL is safe for the updater to connect to.
fn validate_server_url(server_url: &str) -> Result<(), String> {
let trimmed = server_url.trim_end_matches('/');
@@ -80,40 +139,7 @@ pub async fn check_client_update(
app: AppHandle,
server_url: String,
) -> Result<UpdateCheckResult, String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
.clone()
.unwrap_or_else(|| "0.0.0".to_string());
let endpoint = format!(
"{}/api/v1/client-update/{{{{target}}}}/{}",
server_url.trim_end_matches('/'),
current_version,
);
let url: url::Url = endpoint
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// Use TOFU-pinned certificate for self-signed servers, or system certs
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
let updater = build_updater(&app, &server_url)?;
let update = updater
.check()
@@ -142,39 +168,7 @@ pub async fn download_and_install_update(
app: AppHandle,
server_url: String,
) -> Result<(), String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
.clone()
.unwrap_or_else(|| "0.0.0".to_string());
let endpoint = format!(
"{}/api/v1/client-update/{{{{target}}}}/{}",
server_url.trim_end_matches('/'),
current_version,
);
let url: url::Url = endpoint
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// Use TOFU-pinned certificate for self-signed servers (BUG-134).
let tls_config = build_tls_config(&app, &server_url)?;
let mut builder = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?;
if let Some(config) = tls_config {
let config = Arc::new(config);
builder = builder.configure_client(move |client| {
client.use_preconfigured_tls((*config).clone())
});
}
let updater = builder
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
let updater = build_updater(&app, &server_url)?;
let update = updater
.check()
@@ -202,3 +196,28 @@ pub async fn download_and_install_update(
None => Err("no update available".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_includes_target_arch_and_bundle_type_variables() {
// "{{target}}-{{arch}}-{{bundle_type}}" (e.g. "windows-x86_64-nsis")
// must match the "{os}-{arch}-{installer}" key the updater plugin
// looks up first in the response platforms map — the server echoes
// this path segment back as that key.
assert_eq!(
build_update_endpoint("https://chat.example.com/", "1.2.3"),
"https://chat.example.com/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/1.2.3"
);
}
#[test]
fn endpoint_keeps_non_default_port() {
assert_eq!(
build_update_endpoint("https://chat.example.com:8443", "0.0.0"),
"https://chat.example.com:8443/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/0.0.0"
);
}
}