diff --git a/.changes/updater-restore-and-verify.md b/.changes/updater-restore-and-verify.md new file mode 100644 index 000000000..17b85a9e5 --- /dev/null +++ b/.changes/updater-restore-and-verify.md @@ -0,0 +1,8 @@ +--- +updater: minor +updater-js: minor +--- + +Add `Updater::restore_update` to restore an update from cached release metadata, and `Update::verify` to verify package bytes using the configured signing key and signed-version policy. Restored updates always require the signature to bind the announced version. + +`Update::install` now verifies the package bytes before installing them, and `Updater::check` resolves to `None` instead of a target error when the release is not an update and has no entry for the current target. diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index 851fa296e..af709c292 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -32,7 +32,7 @@ ios = { level = "none", notes = "" } tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] -tauri = { workspace = true, features = ["wry"] } +tauri = { workspace = true, features = ["wry", "test"] } [dependencies] tauri = { workspace = true } diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs index e478f77c2..a34f4c031 100644 --- a/plugins/updater/src/updater.rs +++ b/plugins/updater/src/updater.rs @@ -539,11 +539,7 @@ impl Updater { headers.insert(ACCEPT, HeaderValue::from_static("application/json")); } - let target = if let Some(target) = &self.target { - target - } else { - updater_os().ok_or(Error::UnsupportedOs)? - }; + let target = self.current_target()?; let mut remote_release: Option = None; let mut raw_json: Option = None; @@ -666,36 +662,84 @@ impl Updater { // Extracted remote metadata let release = remote_release.ok_or(Error::ReleaseNotFound)?; + self.update_from_release(release, raw_json.unwrap(), target) + } + + /// Restores an update from a previously saved [`Update::raw_json`] without making a request. + /// + /// Applies the same target selection and version comparator as [`Self::check`]. Runtime + /// configuration, including the public key and installation path, comes from this updater, + /// not from the cached metadata. Returns `None` if the release is no longer an update. + /// + /// The cached metadata is not authenticated, so the restored update always requires the + /// package signature to bind the announced version, as if [`Config::require_signed_version`] + /// was enabled. Otherwise modified metadata could pair a newer version number with an older, + /// validly signed package. Packages signed without a version (by Tauri CLI releases that + /// predate `--app-version`) are rejected with [`Error::MissingSignedVersion`]. + /// + /// [`Update::install`] verifies the bytes it is given, and [`Update::verify`] can be used to + /// check a cached package before installing it. + /// + /// # Errors + /// + /// - [`Error::UnsupportedOs`]: no target was set and the updater does not support the + /// current operating system. + /// - A deserialization error when the metadata is not a valid release manifest. + /// - [`Error::TargetNotFound`] or [`Error::TargetsNotFound`]: the release is an update but + /// the manifest has no entry for the current target. + pub fn restore_update(&self, raw_json: serde_json::Value) -> Result> { + let target = self.current_target()?; + let release = serde_json::from_value(raw_json.clone())?; + let mut update = self.update_from_release(release, raw_json, target)?; + if let Some(update) = &mut update { + update.context.config.require_signed_version = true; + } + Ok(update) + } + + /// The `{{target}}` value: the user provided target or the current operating system. + fn current_target(&self) -> Result<&str> { + match &self.target { + Some(target) => Ok(target), + None => updater_os().ok_or(Error::UnsupportedOs), + } + } + + fn update_from_release( + &self, + release: RemoteRelease, + raw_json: serde_json::Value, + target: &str, + ) -> Result> { let should_update = match self.version_comparator.as_ref() { Some(comparator) => comparator(self.current_version.clone(), release.clone()), None => release.version > self.current_version, }; + // a release that is not an update is not an error, even if it has no entry for this target + if !should_update { + return Ok(None); + } + let installer = installer_for_bundle_type(bundle_type()); let (download_url, signature) = self.get_urls(&release, &installer)?; - let update = if should_update { - Some(Update { - current_version: self.current_version.to_string(), - target: target.to_owned(), - extract_path: self.extract_path.clone(), - version: release.version.to_string(), - date: release.pub_date, - download_url: download_url.clone(), - signature: signature.to_owned(), - body: release.notes, - raw_json: raw_json.unwrap(), - timeout: None, - proxy: self.proxy.clone(), - no_proxy: self.no_proxy, - headers: self.headers.clone(), - context: self.context.clone(), - }) - } else { - None - }; - - Ok(update) + Ok(Some(Update { + current_version: self.current_version.to_string(), + target: target.to_owned(), + extract_path: self.extract_path.clone(), + version: release.version.to_string(), + date: release.pub_date, + download_url: download_url.clone(), + signature: signature.to_owned(), + body: release.notes, + raw_json, + timeout: None, + proxy: self.proxy.clone(), + no_proxy: self.no_proxy, + headers: self.headers.clone(), + context: self.context.clone(), + })) } fn get_urls<'a>( @@ -834,25 +878,40 @@ impl Update { } on_download_finish(); - verify_signature( - &buffer, - &self.signature, - &self.context.config.pubkey, - &self.version, - self.context.config.require_signed_version, - )?; + self.verify(&buffer)?; Ok(buffer) } - /// Installs the updater package downloaded by [`Update::download`] + /// Verifies package bytes using this update's signature and the configured public key. + /// + /// This performs the same checks as [`Self::download`] and [`Self::install`], including + /// signed-version validation, without making a request or installing anything. Use it to + /// discard a corrupted or tampered cached package early. + pub fn verify(&self, bytes: &[u8]) -> Result<()> { + verify_signature( + bytes, + &self.signature, + &self.context.config.pubkey, + &self.version, + self.context.config.require_signed_version, + ) + } + + /// Installs the updater package downloaded by [`Update::download`] or cached from a previous + /// download. + /// + /// The bytes are verified with [`Self::verify`] first, so a package that does not match this + /// update's signature is never installed. /// /// ## Platform-specific: /// /// - **Windows:** This function exits the app after launching the updater installer successfully /// - **macOS / Linux:** You need to relaunch the app to run the newly install version pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> { - self.install_inner(bytes.as_ref()) + let bytes = bytes.as_ref(); + self.verify(bytes)?; + self.install_inner(bytes) } /// Downloads and installs the updater package @@ -866,8 +925,9 @@ impl Update { on_chunk: C, on_download_finish: D, ) -> Result<()> { + // `download` already verified the bytes let bytes = self.download(on_chunk, on_download_finish).await?; - self.install(bytes) + self.install_inner(&bytes) } #[cfg(mobile)] @@ -1971,6 +2031,242 @@ fn escape_msi_property_arg(arg: impl AsRef) -> String { } } +#[cfg(test)] +mod offline_tests { + use super::*; + use serde_json::{Value, json}; + + // A throwaway key pair generated with `tauri signer generate`, and the signature of the + // payload `test` made with `tauri signer sign --app-version 2.0.0`. + const PUBLIC_KEY: &str = "untrusted comment: minisign public key: A49EF4746FA6E6D7\nRWTX5qZvdPSepDGWg2bz8n6ArQnBMwgQK1geVosorcbLzcunD+KJ+cYe"; + const SIGNATURE: &str = "untrusted comment: signature from tauri secret key\nRUTX5qZvdPSepPF2zsjvP/L/TQtBLRnWRvaq+4omK1UEugtQp/USrGiI1v762sDy3id3aor7bRC9ETyeot6lMHHrWoZ5hjUM1Qc=\ntrusted comment: timestamp:1791649051\tfile:payload\tversion:2.0.0\nDDq2CLOEl5cQmC2eJRHlnZPVViOZsu1CjpPKQoei4A/XBfkDqGO0B+QMrcrHgEQ5qV3mne75tdFWhJpqUXh9AA=="; + // The public `test` payload vector from minisign-verify, signed without a version. + const UNVERSIONED_PUBLIC_KEY: &str = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; + const UNVERSIONED_SIGNATURE: &str = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1633700835\tfile:test\tprehashed\nwLMDjy9FLAuxZ3q4NlEvkgtyhrr0gtTu6KC4KBJdITbbOeAi1zBIYo0v4iTgt8jJpIidRJnp94ABQkJAgAooBQ=="; + + fn encode(value: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(value) + } + + fn builder() -> UpdaterBuilder { + let app = tauri::test::mock_app(); + let config = Config { + pubkey: encode(PUBLIC_KEY), + ..Default::default() + }; + let mut builder = UpdaterBuilder::new(app.handle(), config) + .target("test-target") + .executable_path("runtime-path/app") + .timeout(Duration::from_secs(30)) + .no_proxy() + .configure_client(|_| panic!("unexpected network request")) + .endpoints(vec!["https://example.invalid/update".parse().unwrap()]) + .unwrap(); + builder.current_version = Version::new(1, 0, 0); + builder + } + + fn updater() -> Updater { + builder().build().unwrap() + } + + fn manifest() -> Value { + json!({ + "version": "2.0.0", + "notes": "Cached release notes", + "pub_date": "2025-01-01T00:00:00Z", + "platforms": { + "test-target": { + "url": "https://example.invalid/package", + "signature": encode(SIGNATURE) + } + } + }) + } + + #[test] + fn restores_release_metadata_with_runtime_context() { + let updater = builder() + .header("x-test", "runtime") + .unwrap() + .build() + .unwrap(); + let mut metadata = manifest(); + metadata["extract_path"] = json!("untrusted-path"); + metadata["config"] = json!({ "pubkey": "untrusted-key" }); + let update = updater.restore_update(metadata.clone()).unwrap().unwrap(); + + assert_eq!(update.raw_json, metadata); + assert_eq!(update.current_version, "1.0.0"); + assert_eq!(update.version, "2.0.0"); + assert_eq!(update.body.as_deref(), Some("Cached release notes")); + assert!(update.date.is_some()); + assert_eq!(update.target, "test-target"); + assert_eq!(update.extract_path, updater.extract_path); + assert_eq!(update.context.config.pubkey, updater.context.config.pubkey); + assert_eq!(update.headers, updater.headers); + assert!(update.no_proxy); + assert!(update.timeout.is_none()); + update.verify(b"test").unwrap(); + } + + #[test] + fn restores_dynamic_manifests() { + let update = updater() + .restore_update(json!({ + "version": "2.0.0", + "url": "https://example.invalid/dynamic-package", + "signature": encode(SIGNATURE) + })) + .unwrap() + .unwrap(); + assert_eq!( + update.download_url.as_str(), + "https://example.invalid/dynamic-package" + ); + update.verify(b"test").unwrap(); + } + + #[test] + fn respects_default_and_custom_version_comparators() { + let mut updater = updater(); + for version in ["0.9.0", "1.0.0"] { + let mut metadata = manifest(); + metadata["version"] = json!(version); + assert!(updater.restore_update(metadata).unwrap().is_none()); + } + updater.version_comparator = Some(Arc::new(|_, _| false)); + assert!(updater.restore_update(manifest()).unwrap().is_none()); + updater.version_comparator = Some(Arc::new(|current, release| release.version != current)); + let mut metadata = manifest(); + metadata["version"] = json!("0.9.0"); + assert!(updater.restore_update(metadata).unwrap().is_some()); + } + + #[test] + fn stale_release_without_this_target_is_not_an_update() { + let mut metadata = manifest(); + metadata["version"] = json!("0.9.0"); + metadata["platforms"] = json!({ "other-target": metadata["platforms"]["test-target"] }); + assert!(updater().restore_update(metadata).unwrap().is_none()); + } + + #[test] + fn rejects_malformed_and_incompatible_manifests() { + let updater = updater(); + assert!(updater.restore_update(json!({})).is_err()); + let mut metadata = manifest(); + metadata["platforms"] = json!({}); + assert!(matches!( + updater.restore_update(metadata), + Err(Error::TargetNotFound(_)) + )); + let mut metadata = manifest(); + metadata["version"] = json!("not-a-version"); + assert!(updater.restore_update(metadata).is_err()); + } + + #[test] + fn verifies_cached_bytes_and_rejects_tampering() { + let mut update = updater().restore_update(manifest()).unwrap().unwrap(); + update.verify(b"test").unwrap(); + assert!(update.verify(b"tampered").is_err()); + update.signature = encode(&SIGNATURE.replace("file:payload", "file:other")); + assert!(update.verify(b"test").is_err()); + update.signature = "invalid signature".into(); + assert!(update.verify(b"test").is_err()); + update.signature = encode(SIGNATURE); + update.context.config.pubkey = encode(&PUBLIC_KEY.replace("RWTX", "RWTY")); + assert!(update.verify(b"test").is_err()); + } + + #[test] + fn install_rejects_bytes_that_do_not_match_the_signature() { + let update = updater().restore_update(manifest()).unwrap().unwrap(); + assert!(matches!( + update.install(b"tampered"), + Err(Error::Minisign(_)) + )); + } + + #[test] + fn restored_updates_require_a_signed_version() { + let mut builder = builder().pubkey(encode(UNVERSIONED_PUBLIC_KEY)); + builder.context.config.require_signed_version = false; + let updater = builder.build().unwrap(); + let mut metadata = manifest(); + metadata["platforms"]["test-target"]["signature"] = json!(encode(UNVERSIONED_SIGNATURE)); + metadata["config"] = json!({ "requireSignedVersion": false }); + let update = updater.restore_update(metadata).unwrap().unwrap(); + assert!(matches!( + update.verify(b"test"), + Err(Error::MissingSignedVersion) + )); + assert!(matches!( + update.install(b"test"), + Err(Error::MissingSignedVersion) + )); + } + + #[test] + fn restored_updates_reject_a_version_the_package_was_not_signed_for() { + let mut metadata = manifest(); + metadata["version"] = json!("3.0.0"); + let update = updater().restore_update(metadata).unwrap().unwrap(); + assert!(matches!( + update.verify(b"test"), + Err(Error::SignedVersionMismatch { .. }) + )); + } + + #[test] + fn online_and_restored_updates_use_the_same_metadata() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let metadata = manifest(); + let response = metadata.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(&mut socket); + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + if line == "\r\n" { + break; + } + } + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap(); + }); + let mut updater = updater(); + updater.context.configure_client = None; + updater.endpoints = vec![format!("http://{address}").parse().unwrap()]; + let online = tauri::async_runtime::block_on(updater.check()) + .unwrap() + .unwrap(); + server.join().unwrap(); + let restored = updater + .restore_update(online.raw_json.clone()) + .unwrap() + .unwrap(); + assert_eq!(restored.raw_json, metadata); + assert_eq!(restored.version, online.version); + assert_eq!(restored.current_version, online.current_version); + assert_eq!(restored.target, online.target); + assert_eq!(restored.download_url, online.download_url); + assert_eq!(restored.signature, online.signature); + assert_eq!(restored.extract_path, online.extract_path); + assert_eq!(restored.timeout, online.timeout); + assert!(!online.context.config.require_signed_version); + assert!(restored.context.config.require_signed_version); + } +} + #[cfg(test)] mod tests { use super::{signed_version, verify_signed_version};