tauri_plugin_hot_update/manifest.rs
1//! The signed update manifest: schema, signature verification, validation.
2//!
3//! Verification order is fixed and security-relevant (design doc, "Manifest
4//! and signing"): the minisign signature is verified over the **raw manifest
5//! bytes first**, against the embedded trusted-key LIST (any key may verify —
6//! that is how key rotation works: a store release adds the new key, both
7//! sign during the transition, the old key is dropped later). Only then are
8//! the bytes parsed as JSON and sanity-validated. Nothing downstream ever
9//! touches unverified data.
10
11use minisign_verify::{PublicKey, Signature};
12use semver::Version;
13use serde::{Deserialize, Serialize};
14
15use crate::{Error, Result};
16
17/// The update manifest, exactly as published next to its detached
18/// `.minisig` signature.
19///
20/// Unknown fields are deliberately tolerated (no `deny_unknown_fields`):
21/// newer manifest schema additions must not break older installed shells —
22/// the signature already guarantees the whole document is trusted.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct Manifest {
26 /// Bundle version; must be strictly newer than the shell's watermark.
27 pub version: Version,
28 /// Informational publish timestamp (RFC 3339). Not used in any gate —
29 /// version ordering is what matters — so it stays an opaque string.
30 pub created_at: String,
31 /// Minimum shell (app) version able to run this bundle.
32 pub min_shell_version: Version,
33 pub archive: ArchiveInfo,
34}
35
36/// Where the bundle archive lives and what it must hash to.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ArchiveInfo {
40 pub url: String,
41 /// Hex sha256 of the archive bytes. Canonicalized to lowercase at parse
42 /// time so it can serve as the blacklist identity in `state.json`.
43 pub sha256: String,
44 /// Exact archive byte count; the download aborts past it.
45 pub size: u64,
46}
47
48/// Verify `signature` (the `.minisig` file contents) over the raw
49/// `manifest_bytes` against the trusted key list, then parse and validate.
50///
51/// Any single trusted key verifying is sufficient (rotation). A malformed
52/// key anywhere in the list is a hard [`Error::InvalidPublicKey`] — a broken
53/// trust anchor is a config bug that must surface, not be skipped.
54pub fn verify_and_parse(
55 manifest_bytes: &[u8],
56 signature: &str,
57 trusted_pubkeys: &[String],
58) -> Result<Manifest> {
59 if trusted_pubkeys.is_empty() {
60 return Err(Error::InvalidPublicKey);
61 }
62 // Parse every trust anchor before verifying anything: a malformed key
63 // must surface even when an earlier key in the list would verify.
64 let keys = trusted_pubkeys
65 .iter()
66 .map(|key| parse_public_key(key))
67 .collect::<Result<Vec<_>>>()?;
68 let signature = Signature::decode(signature)
69 .map_err(|e| Error::ManifestSignature(format!("undecodable .minisig data: {e}")))?;
70
71 let verified = keys
72 .iter()
73 .any(|key| key.verify(manifest_bytes, &signature, false).is_ok());
74 if !verified {
75 return Err(Error::ManifestSignature(
76 "no trusted public key verified the manifest".into(),
77 ));
78 }
79
80 let mut manifest: Manifest = serde_json::from_slice(manifest_bytes)?;
81 validate(&mut manifest)?;
82 Ok(manifest)
83}
84
85/// Init-time check that every configured trust anchor parses as minisign
86/// key material ([`verify_and_parse`] re-parses at use). Same hard-stop rule
87/// as verification: one malformed key fails the whole list.
88pub(crate) fn validate_pubkeys(keys: &[String]) -> Result<()> {
89 keys.iter()
90 .try_for_each(|key| parse_public_key(key).map(drop))
91}
92
93/// Accept either the raw base64 key (`RW…`) or the full two-line
94/// `minisign.pub` file contents.
95fn parse_public_key(key: &str) -> Result<PublicKey> {
96 let key = key.trim();
97 let result = if key.lines().count() > 1 {
98 PublicKey::decode(key)
99 } else {
100 PublicKey::from_base64(key)
101 };
102 result.map_err(|_| Error::InvalidPublicKey)
103}
104
105/// Sanity checks on the (already signature-verified) manifest, plus sha256
106/// canonicalization to lowercase.
107fn validate(manifest: &mut Manifest) -> Result<()> {
108 let sha = &manifest.archive.sha256;
109 if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
110 return Err(Error::ManifestInvalid(format!(
111 "archive.sha256 {sha:?} is not 64 hex characters"
112 )));
113 }
114 manifest.archive.sha256 = manifest.archive.sha256.to_ascii_lowercase();
115
116 let size = manifest.archive.size;
117 if size == 0 || size > crate::extract::MAX_UNCOMPRESSED_BYTES {
118 return Err(Error::ManifestInvalid(format!(
119 "archive.size {size} is outside 1..={}",
120 crate::extract::MAX_UNCOMPRESSED_BYTES
121 )));
122 }
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests;