//! Signed, data-only hotfix channel: selector and flag overrides for site markup changes. //! //! The body is JSON (`{version, expires, minNative, selectors{}, flags{}}`) with a detached //! minisign signature from a separate hotfix key. It is verified, schema-checked, required to be //! newer than the copy already held (monotonic) and unexpired, and then handed to site tabs as //! frozen data (`window.`). It is never evaluated as code: a leaked hotfix key can at most //! break selectors. The app owns the public key and channel URL. use base64::Engine; use chrono::{DateTime, Utc}; use minisign_verify::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; pub const MAX_BODY_BYTES: usize = 64 * 1024; pub const MAX_SIG_BYTES: usize = 4 * 1024; const MAX_SELECTORS: usize = 256; const MAX_FLAGS: usize = 128; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Hotfix { /// Monotonic; a client never accepts a version at or below the one it holds. pub version: u64, /// RFC 3339 timestamp after which the hotfix is ignored (blocks stale replay). pub expires: String, /// Lowest native app version this data is written for. pub min_native: String, #[serde(default)] pub selectors: BTreeMap, #[serde(default)] pub flags: BTreeMap, } /// Accepts either the raw minisign text or the base64 wrapping that the Tauri signer produces. pub fn minisign_text(value: &str) -> Result { let trimmed = value.trim(); if trimmed.starts_with("untrusted comment:") { return Ok(trimmed.to_owned()); } let bytes = base64::engine::general_purpose::STANDARD .decode(trimmed) .map_err(|_| "HOTFIX: key or signature is not minisign text or base64")?; String::from_utf8(bytes).map_err(|_| "HOTFIX: key or signature is not UTF-8".into()) } /// Verifies `body` against a detached minisign signature. pub fn verify(pubkey: &str, body: &[u8], signature: &str) -> Result<(), String> { if signature.len() > MAX_SIG_BYTES { return Err("HOTFIX: signature too large".into()); } let key_text = minisign_text(pubkey)?; let key_line = key_text .lines() .map(str::trim) .find(|line| !line.is_empty() && !line.starts_with("untrusted comment:")) .ok_or("HOTFIX: empty public key")?; let key = PublicKey::from_base64(key_line).map_err(|_| "HOTFIX: invalid public key")?; let signature = Signature::decode(&minisign_text(signature)?).map_err(|_| "HOTFIX: invalid signature")?; key.verify(body, &signature, false) .map_err(|_| "HOTFIX: signature verification failed".into()) } fn semver_triplet(value: &str) -> Option<(u64, u64, u64, bool)> { let (core, pre) = match value.split_once('-') { Some((core, pre)) => (core, !pre.is_empty()), None => (value, false), }; let mut parts = core.split('.').map(|p| p.parse::().ok()); let triplet = (parts.next()??, parts.next()??, parts.next()??, pre); parts.next().is_none().then_some(triplet) } /// `a >= b` for semantic versions (a prerelease sorts before its release). pub fn version_at_least(a: &str, b: &str) -> Option { let (a, b) = (semver_triplet(a)?, semver_triplet(b)?); Some(match (a.0, a.1, a.2).cmp(&(b.0, b.1, b.2)) { std::cmp::Ordering::Greater => true, std::cmp::Ordering::Less => false, std::cmp::Ordering::Equal => !a.3 || b.3, }) } fn valid_key(key: &str) -> bool { !key.is_empty() && key.len() <= 64 && key.bytes().all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c)) } /// Schema, expiry and native-version checks on an already verified body. pub fn validate(body: &[u8], now: DateTime, native_version: &str) -> Result { if body.len() > MAX_BODY_BYTES { return Err("HOTFIX: body too large".into()); } let hotfix: Hotfix = serde_json::from_slice(body).map_err(|error| format!("HOTFIX: schema: {error}"))?; let expires = DateTime::parse_from_rfc3339(&hotfix.expires).map_err(|_| "HOTFIX: invalid expires")?; if expires.with_timezone(&Utc) <= now { return Err("HOTFIX: expired".into()); } match version_at_least(native_version, &hotfix.min_native) { Some(true) => {} Some(false) => return Err("HOTFIX: requires a newer app".into()), None => return Err("HOTFIX: invalid minNative".into()), } if hotfix.selectors.len() > MAX_SELECTORS || hotfix.selectors.iter().any(|(k, v)| !valid_key(k) || v.is_empty() || v.len() > 1024) { return Err("HOTFIX: invalid selectors".into()); } let scalar = |v: &serde_json::Value| match v { serde_json::Value::Bool(_) | serde_json::Value::Number(_) => true, serde_json::Value::String(s) => s.len() <= 256, _ => false, }; if hotfix.flags.len() > MAX_FLAGS || hotfix.flags.iter().any(|(k, v)| !valid_key(k) || !scalar(v)) { return Err("HOTFIX: invalid flags".into()); } Ok(hotfix) } #[derive(Debug, PartialEq)] pub enum Outcome { /// A newer valid hotfix to store and deliver. Accepted(Hotfix), /// Same version as the one already held; nothing to do. Unchanged, } /// Full client-side acceptance: signature first, then schema/expiry/minNative, then monotonicity. pub fn accept( pubkey: &str, body: &[u8], signature: &str, now: DateTime, native_version: &str, held_version: Option, ) -> Result { verify(pubkey, body, signature)?; let hotfix = validate(body, now, native_version)?; match held_version { Some(held) if hotfix.version == held => Ok(Outcome::Unchanged), Some(held) if hotfix.version < held => Err("HOTFIX: older than the held version".into()), _ => Ok(Outcome::Accepted(hotfix)), } } /// Delivers the hotfix to a site page as data: parsed from a JSON string literal, frozen, stored /// as `window.` and announced with the `event` window event. pub fn script(hotfix: &Hotfix, global: &str, event: &str) -> String { let json = serde_json::to_string(hotfix).unwrap_or_else(|_| "null".into()); let literal = serde_json::to_string(&json).unwrap_or_else(|_| "\"null\"".into()); format!( "(()=>{{const h=JSON.parse({literal});if(h){{Object.freeze(h.selectors);Object.freeze(h.flags);Object.freeze(h);}}window.{global}=h;try{{window.dispatchEvent(new CustomEvent('{event}'));}}catch(_){{}}}})();" ) } #[cfg(test)] mod tests { use super::*; #[test] fn semver_ordering() { assert_eq!(version_at_least("0.2.0", "0.2.0"), Some(true)); assert_eq!(version_at_least("0.10.0", "0.9.9"), Some(true)); assert_eq!(version_at_least("0.2.0-beta.1", "0.2.0"), Some(false)); assert_eq!(version_at_least("0.2.0", "0.2.0-beta.1"), Some(true)); assert_eq!(version_at_least("0.2", "0.2.0"), None); } }