hololake-system-architecture/product-source/hololake-native-desktop/src-tauri/src/release_update.rs

581 lines
21 KiB
Rust

// 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<String, Value>,
hololake: HoloLakeMetadata,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct HoloLakeMetadata {
features: Vec<String>,
#[serde(default)]
fixes: Vec<String>,
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<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RestartMetadata {
required: bool,
automatic_allowed: bool,
message: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RollbackMetadata {
supported: bool,
health_receipt_required: bool,
previous_version: Option<String>,
}
#[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<String>,
fixes: Vec<String>,
minimum_version: String,
data_migration_required: bool,
compatibility_notes: Option<String>,
restart_message: Option<String>,
rollback_supported: bool,
previous_version: Option<String>,
expires_at_unix_ms: u128,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseCheckReceipt {
schema: &'static str,
state: &'static str,
candidate: Option<ReleaseCandidateReceipt>,
}
#[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<ReleaseCheckReceipt, String> {
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<ReleaseInstallReceipt, String> {
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<UpdateSnapshot, String> {
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::<PlatformRelease>(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<ReleaseCandidateReceipt, String> {
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<ReleaseCandidateRecord, String> {
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<PathBuf, String> {
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<std::fs::File, String> {
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<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
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<T: Serialize>(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<u128, String> {
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"
);
}
}