Skip to main content

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;