mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-10 22:38:44 +02:00
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:
1 parent
b53d198461
commit
fce235c3fb
3 files changed
+342
-38
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user