feat(updater): restore cached updates and verify package bytes (#3640)

* feat(updater): restore cached updates and verify package bytes

* fix(updater): keep cached update tests in the existing module

* require signed version, verify bytes, cleanup

* fix bump

* fmt

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
t8y2andLucas Nogueira authored and GitHub committed 2026-10-10 14:12:13 -03:00
1 parent b53d198461
commit fce235c3fb
3 files changed
+342 -38

No files matched your search

+8
View File
@@ -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.
+1 -1
View File
@@ -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 }
+333 -37
View File
@@ -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<RemoteRelease> = None;
let mut raw_json: Option<serde_json::Value> = 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<Option<Update>> {
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<Option<Update>> {
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<OsStr>) -> 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};