// SPDX-License-Identifier: AGPL-3.0-or-later use crate::release_trust::provisioned_release_trust; use fs2::FileExt; use ring::digest::{digest, SHA256}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; use tauri_plugin_updater::{Update, UpdaterExt}; use uuid::Uuid; const CANDIDATE_SCHEMA: &str = "hololake.release-candidate/v1"; const RECEIPT_SCHEMA: &str = "hololake.release-install-receipt/v1"; const CANDIDATE_TTL_MS: u128 = 30 * 60 * 1000; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ReleaseCandidateRecord { schema: String, state: String, candidate_id: String, confirmation_token_sha256: String, release_id: String, current_version: String, version: String, raw_broadcast_sha256: String, download_url: String, package_sha256: String, package_size: u64, issued_at_unix_ms: u128, expires_at_unix_ms: u128, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ConfirmReleaseInstallInput { candidate_id: String, confirmation_token: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ReleaseEnvelope { schema: String, release_id: String, version: String, notes: String, platforms: serde_json::Map, hololake: HoloLakeMetadata, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct HoloLakeMetadata { features: Vec, #[serde(default)] fixes: Vec, compatibility: CompatibilityMetadata, restart: RestartMetadata, rollback: RollbackMetadata, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct CompatibilityMetadata { minimum_version: String, data_migration_required: bool, notes: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RestartMetadata { required: bool, automatic_allowed: bool, message: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RollbackMetadata { supported: bool, health_receipt_required: bool, previous_version: Option, } #[derive(Clone, Debug, Deserialize)] struct PlatformRelease { url: String, signature: String, size: u64, sha256: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReleaseCandidateReceipt { schema: &'static str, state: &'static str, candidate_id: String, confirmation_token: String, release_id: String, current_version: String, version: String, notes: String, features: Vec, fixes: Vec, minimum_version: String, data_migration_required: bool, compatibility_notes: Option, restart_message: Option, rollback_supported: bool, previous_version: Option, expires_at_unix_ms: u128, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReleaseCheckReceipt { schema: &'static str, state: &'static str, candidate: Option, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReleaseInstallReceipt { schema: &'static str, state: &'static str, release_id: String, previous_version: String, installed_version: String, package_sha256: String, package_size: u64, automatic_restart: bool, health_receipt_required: bool, rollback_declared_by_broadcast: bool, receipt_id: String, } #[tauri::command] pub async fn check_hololake_update(app: AppHandle) -> Result { let trust = provisioned_release_trust()? .ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?; let update = app .updater_builder() .endpoints(trust.endpoints.clone()) .map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))? .build() .map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))? .check() .await .map_err(|error| format!("HOLOLAKE_RELEASE_CHECK_FAILED: {error}"))?; let Some(update) = update else { return Ok(ReleaseCheckReceipt { schema: CANDIDATE_SCHEMA, state: "CURRENT_VERSION_IS_LATEST", candidate: None, }); }; let snapshot = validate_update(&update, &trust.allowed_release_host)?; let root = release_update_root(&app)?; let candidate = create_candidate_at(&root, &snapshot)?; Ok(ReleaseCheckReceipt { schema: CANDIDATE_SCHEMA, state: "UPDATE_AVAILABLE_AWAITING_HUMAN_CONFIRMATION", candidate: Some(candidate), }) } #[tauri::command] pub async fn confirm_hololake_update_install( app: AppHandle, input: ConfirmReleaseInstallInput, ) -> Result { let trust = provisioned_release_trust()? .ok_or_else(|| "HOLOLAKE_RELEASE_TRUST_UNPROVISIONED_NO_NETWORK_REQUEST".to_string())?; let root = release_update_root(&app)?; let record = authorize_candidate_at(&root, &input)?; let update = app .updater_builder() .endpoints(trust.endpoints.clone()) .map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))? .build() .map_err(|error| format!("HOLOLAKE_RELEASE_UPDATER_INVALID: {error}"))? .check() .await .map_err(|error| format!("HOLOLAKE_RELEASE_RECHECK_FAILED: {error}"))? .ok_or_else(|| "HOLOLAKE_RELEASE_CANDIDATE_NO_LONGER_AVAILABLE".to_string())?; let snapshot = validate_update(&update, &trust.allowed_release_host)?; require_same_candidate(&record, &snapshot)?; let bytes = update .download(|_, _| {}, || {}) .await .map_err(|error| format!("HOLOLAKE_RELEASE_DOWNLOAD_OR_SIGNATURE_FAILED: {error}"))?; if bytes.len() as u64 != snapshot.package_size || sha256_hex(&bytes) != snapshot.package_sha256 { return Err("HOLOLAKE_RELEASE_PACKAGE_DIGEST_OR_SIZE_MISMATCH".into()); } let receipt = ReleaseInstallReceipt { schema: RECEIPT_SCHEMA, state: "SIGNED_PACKAGE_VERIFIED_INSTALL_STARTED_RESTART_REQUIRED", release_id: snapshot.envelope.release_id.clone(), previous_version: snapshot.current_version.clone(), installed_version: snapshot.envelope.version.clone(), package_sha256: snapshot.package_sha256.clone(), package_size: snapshot.package_size, automatic_restart: false, health_receipt_required: snapshot.envelope.hololake.rollback.health_receipt_required, rollback_declared_by_broadcast: snapshot.envelope.hololake.rollback.supported, receipt_id: sha256_hex( format!( "{}\n{}\n{}\n{}", snapshot.envelope.release_id, snapshot.current_version, snapshot.envelope.version, snapshot.package_sha256 ) .as_bytes(), ), }; write_json_atomic( &root.join("pending-install-receipt.json"), &receipt, "RECEIPT", )?; mark_candidate_installing_at(&root, &record.candidate_id)?; update .install(&bytes) .map_err(|error| format!("HOLOLAKE_RELEASE_INSTALL_FAILED: {error}"))?; Ok(receipt) } struct UpdateSnapshot { current_version: String, envelope: ReleaseEnvelope, raw_broadcast_sha256: String, download_url: String, package_sha256: String, package_size: u64, } fn validate_update(update: &Update, allowed_host: &str) -> Result { let raw = serde_json::to_vec(&update.raw_json) .map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?; let envelope: ReleaseEnvelope = serde_json::from_value(update.raw_json.clone()) .map_err(|error| format!("HOLOLAKE_RELEASE_BROADCAST_INVALID: {error}"))?; if envelope.schema != "hololake.release-broadcast/v1" || envelope.release_id.trim().is_empty() || envelope.version != update.version || envelope.notes.trim().is_empty() || envelope.hololake.features.is_empty() || !envelope.hololake.restart.required || envelope.hololake.restart.automatic_allowed || !envelope.hololake.rollback.supported || !envelope.hololake.rollback.health_receipt_required { return Err("HOLOLAKE_RELEASE_BROADCAST_POLICY_INVALID".into()); } let url = &update.download_url; if url.scheme() != "https" || url.host_str() != Some(allowed_host) { return Err("HOLOLAKE_RELEASE_PACKAGE_HOST_NOT_TRUSTED".into()); } let platform = envelope .platforms .values() .filter_map(|value| serde_json::from_value::(value.clone()).ok()) .find(|platform| platform.url == url.as_str() && platform.signature == update.signature) .ok_or("HOLOLAKE_RELEASE_PLATFORM_EVIDENCE_MISSING")?; validate_sha256(&platform.sha256)?; if platform.size == 0 { return Err("HOLOLAKE_RELEASE_PACKAGE_SIZE_INVALID".into()); } Ok(UpdateSnapshot { current_version: update.current_version.clone(), envelope, raw_broadcast_sha256: sha256_hex(&raw), download_url: url.to_string(), package_sha256: platform.sha256, package_size: platform.size, }) } fn create_candidate_at( root: &Path, snapshot: &UpdateSnapshot, ) -> Result { let _lock = lock(root)?; let issued_at_unix_ms = now_unix_ms()?; let expires_at_unix_ms = issued_at_unix_ms + CANDIDATE_TTL_MS; let candidate_id = format!("release-{}", Uuid::new_v4()); let confirmation_token = format!("install-{}-{}", Uuid::new_v4(), Uuid::new_v4()); let record = ReleaseCandidateRecord { schema: CANDIDATE_SCHEMA.into(), state: "PENDING_HUMAN_CONFIRMATION".into(), candidate_id: candidate_id.clone(), confirmation_token_sha256: sha256_hex(confirmation_token.as_bytes()), release_id: snapshot.envelope.release_id.clone(), current_version: snapshot.current_version.clone(), version: snapshot.envelope.version.clone(), raw_broadcast_sha256: snapshot.raw_broadcast_sha256.clone(), download_url: snapshot.download_url.clone(), package_sha256: snapshot.package_sha256.clone(), package_size: snapshot.package_size, issued_at_unix_ms, expires_at_unix_ms, }; write_json_atomic(&candidate_path(root, &candidate_id), &record, "CANDIDATE")?; Ok(ReleaseCandidateReceipt { schema: CANDIDATE_SCHEMA, state: "AWAITING_HUMAN_DOWNLOAD_AND_INSTALL_CONFIRMATION", candidate_id, confirmation_token, release_id: snapshot.envelope.release_id.clone(), current_version: snapshot.current_version.clone(), version: snapshot.envelope.version.clone(), notes: snapshot.envelope.notes.clone(), features: snapshot.envelope.hololake.features.clone(), fixes: snapshot.envelope.hololake.fixes.clone(), minimum_version: snapshot .envelope .hololake .compatibility .minimum_version .clone(), data_migration_required: snapshot .envelope .hololake .compatibility .data_migration_required, compatibility_notes: snapshot.envelope.hololake.compatibility.notes.clone(), restart_message: snapshot.envelope.hololake.restart.message.clone(), rollback_supported: snapshot.envelope.hololake.rollback.supported, previous_version: snapshot.envelope.hololake.rollback.previous_version.clone(), expires_at_unix_ms, }) } fn authorize_candidate_at( root: &Path, input: &ConfirmReleaseInstallInput, ) -> Result { validate_machine_id(&input.candidate_id)?; if input.confirmation_token.len() < 32 || input.confirmation_token.len() > 256 { return Err("HOLOLAKE_RELEASE_CONFIRMATION_TOKEN_INVALID".into()); } let _lock = lock(root)?; let record: ReleaseCandidateRecord = read_json(&candidate_path(root, &input.candidate_id))?; if record.schema != CANDIDATE_SCHEMA || record.candidate_id != input.candidate_id { return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into()); } if record.confirmation_token_sha256 != sha256_hex(input.confirmation_token.as_bytes()) { return Err("HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED".into()); } if record.state != "PENDING_HUMAN_CONFIRMATION" || now_unix_ms()? > record.expires_at_unix_ms { return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into()); } Ok(record) } fn require_same_candidate( record: &ReleaseCandidateRecord, snapshot: &UpdateSnapshot, ) -> Result<(), String> { if record.release_id != snapshot.envelope.release_id || record.current_version != snapshot.current_version || record.version != snapshot.envelope.version || record.raw_broadcast_sha256 != snapshot.raw_broadcast_sha256 || record.download_url != snapshot.download_url || record.package_sha256 != snapshot.package_sha256 || record.package_size != snapshot.package_size { return Err("HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED".into()); } Ok(()) } fn mark_candidate_installing_at(root: &Path, candidate_id: &str) -> Result<(), String> { let _lock = lock(root)?; let path = candidate_path(root, candidate_id); let mut record: ReleaseCandidateRecord = read_json(&path)?; if record.state != "PENDING_HUMAN_CONFIRMATION" { return Err("HOLOLAKE_RELEASE_CANDIDATE_EXPIRED_OR_CONSUMED".into()); } record.state = "INSTALLING".into(); write_json_atomic(&path, &record, "CANDIDATE") } fn release_update_root(app: &AppHandle) -> Result { let root = app .path() .app_data_dir() .map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))? .join("release-update-v1"); fs::create_dir_all(&root) .map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() .map_err(|error| format!("HOLOLAKE_RELEASE_STORAGE_UNAVAILABLE: {error}")) } fn candidate_path(root: &Path, candidate_id: &str) -> PathBuf { root.join(format!("{candidate_id}.json")) } fn lock(root: &Path) -> Result { let lock = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .mode(0o600) .open(root.join("release.lock")) .map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?; lock.lock_exclusive() .map_err(|error| format!("HOLOLAKE_RELEASE_LOCK_FAILED: {error}"))?; Ok(lock) } fn read_json Deserialize<'de>>(path: &Path) -> Result { let metadata = fs::symlink_metadata(path) .map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?; if !metadata.is_file() || metadata.file_type().is_symlink() { return Err("HOLOLAKE_RELEASE_CANDIDATE_INVALID".into()); } serde_json::from_slice( &fs::read(path) .map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_READ_FAILED: {error}"))?, ) .map_err(|error| format!("HOLOLAKE_RELEASE_CANDIDATE_INVALID: {error}")) } fn write_json_atomic(path: &Path, value: &T, label: &str) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| format!("HOLOLAKE_RELEASE_{label}_PATH_INVALID"))?; let temporary = parent.join(format!(".{label}-{}.tmp", Uuid::new_v4())); let bytes = serde_json::to_vec_pretty(value) .map_err(|error| format!("HOLOLAKE_RELEASE_{label}_INVALID: {error}"))?; let mut file = OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(&temporary) .map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?; file.write_all(&bytes) .and_then(|_| file.sync_all()) .map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}"))?; fs::rename(&temporary, path) .map_err(|error| format!("HOLOLAKE_RELEASE_{label}_WRITE_FAILED: {error}")) } fn validate_machine_id(value: &str) -> Result<(), String> { if value.is_empty() || value.len() > 128 || !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) { return Err("HOLOLAKE_RELEASE_CANDIDATE_ID_INVALID".into()); } Ok(()) } fn validate_sha256(value: &str) -> Result<(), String> { if value.len() != 64 || !value .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return Err("HOLOLAKE_RELEASE_PACKAGE_SHA256_INVALID".into()); } Ok(()) } fn now_unix_ms() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .map_err(|error| format!("HOLOLAKE_RELEASE_CLOCK_INVALID: {error}")) } fn sha256_hex(bytes: &[u8]) -> String { digest(&SHA256, bytes) .as_ref() .iter() .map(|byte| format!("{byte:02x}")) .collect() } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn snapshot() -> UpdateSnapshot { UpdateSnapshot { current_version: "0.1.0".into(), envelope: ReleaseEnvelope { schema: "hololake.release-broadcast/v1".into(), release_id: "release-1".into(), version: "0.2.0".into(), notes: "First signed update".into(), platforms: serde_json::Map::new(), hololake: HoloLakeMetadata { features: vec!["Personal channel".into()], fixes: vec![], compatibility: CompatibilityMetadata { minimum_version: "0.1.0".into(), data_migration_required: false, notes: None, }, restart: RestartMetadata { required: true, automatic_allowed: false, message: Some("Restart yourself".into()), }, rollback: RollbackMetadata { supported: true, health_receipt_required: true, previous_version: Some("0.1.0".into()), }, }, }, raw_broadcast_sha256: "1".repeat(64), download_url: "https://release.guanghu.test/HoloLake.tar.gz".into(), package_sha256: "2".repeat(64), package_size: 42, } } #[test] fn installation_requires_the_exact_short_lived_human_candidate() { let root = TempDir::new().unwrap(); let candidate = create_candidate_at(root.path(), &snapshot()).unwrap(); let wrong = authorize_candidate_at( root.path(), &ConfirmReleaseInstallInput { candidate_id: candidate.candidate_id.clone(), confirmation_token: "wrong-confirmation-token-long-enough-123".into(), }, ) .unwrap_err(); assert_eq!(wrong, "HOLOLAKE_RELEASE_CANDIDATE_NOT_AUTHORIZED"); let authorized = authorize_candidate_at( root.path(), &ConfirmReleaseInstallInput { candidate_id: candidate.candidate_id, confirmation_token: candidate.confirmation_token, }, ) .unwrap(); assert_eq!(authorized.version, "0.2.0"); } #[test] fn any_broadcast_or_package_change_requires_a_new_confirmation() { let root = TempDir::new().unwrap(); let original = snapshot(); let candidate = create_candidate_at(root.path(), &original).unwrap(); let record = authorize_candidate_at( root.path(), &ConfirmReleaseInstallInput { candidate_id: candidate.candidate_id, confirmation_token: candidate.confirmation_token, }, ) .unwrap(); let mut changed = snapshot(); changed.package_sha256 = "3".repeat(64); assert_eq!( require_same_candidate(&record, &changed).unwrap_err(), "HOLOLAKE_RELEASE_BROADCAST_CHANGED_RECONFIRM_REQUIRED" ); } }