diff --git a/Cargo.lock b/Cargo.lock index 7f4c94c..55f9909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,6 +458,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "2.0.1" @@ -764,7 +770,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -864,6 +870,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -1189,6 +1201,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1204,6 +1243,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1386,6 +1435,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.17.0" @@ -1549,6 +1622,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1621,12 +1700,15 @@ dependencies = [ name = "floatile-cli" version = "0.1.0" dependencies = [ + "base64", + "ed25519-dalek", "floatile-core", "floatile-platform", "floatile-plugin-api", "floatile-runtime", "floatile-store", "floatile-ui-schema", + "semver", "serde", "serde_json", "thiserror 2.0.20", @@ -1648,6 +1730,8 @@ dependencies = [ name = "floatile-core" version = "0.1.0" dependencies = [ + "base64", + "ed25519-dalek", "jsonschema", "schemars", "semver", @@ -2128,7 +2212,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -4369,6 +4453,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4599,7 +4693,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4631,7 +4725,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -4646,7 +4749,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -5267,6 +5370,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5565,6 +5677,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 67e456e..91dff15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ wit-bindgen = "0.60" zip = { version = "2", default-features = false, features = ["deflate"] } parking_lot = "0.12" sha2 = "0.10" +base64 = "0.22" +ed25519-dalek = "2.2" [workspace.lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/crates/floatile-cli/Cargo.toml b/crates/floatile-cli/Cargo.toml index 7eedc86..fa56b59 100644 --- a/crates/floatile-cli/Cargo.toml +++ b/crates/floatile-cli/Cargo.toml @@ -22,6 +22,7 @@ floatile-store = { workspace = true } floatile-ui-schema = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +semver = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } toml = { workspace = true } @@ -29,4 +30,6 @@ wasmtime = { workspace = true } zip = { workspace = true } [dev-dependencies] +base64 = { workspace = true } +ed25519-dalek = { workspace = true } floatile-plugin-api = { workspace = true } diff --git a/crates/floatile-cli/src/install.rs b/crates/floatile-cli/src/install.rs index 7059fd5..ab6a13f 100644 --- a/crates/floatile-cli/src/install.rs +++ b/crates/floatile-cli/src/install.rs @@ -14,8 +14,19 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use floatile_core::install::{InstallMeta, content_digest, file_digest, hex_encode}; +use floatile_core::distribution::{ + SIGNATURE_FILE, SignatureVerificationError, UpgradePlan, UpgradePlanError, plan_upgrade, + signable_content_digest, verify_signature_envelope, +}; +use floatile_core::install::{ + InstallMeta, InstallationTrust, content_digest, file_digest, hex_encode, +}; use floatile_core::manifest::Manifest; +use floatile_store::Store; +use floatile_store::installation::InstalledInstallation; +use floatile_store::installation::{InstallationCatalogError, load_highest}; +use floatile_store::trust::{PendingInstallation, TrustPolicyError}; +use semver::Version; use thiserror::Error; use crate::package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; @@ -35,6 +46,24 @@ pub enum InstallError { Commit(String), #[error("I/O 失败: {0}")] Io(String), + #[error("包缺少 detached signature.json")] + MissingSignature, + #[error("找不到 manifest publisher 的宿主信任记录")] + UnknownPublisher, + #[error("签名验证失败: {0}")] + Signature(#[from] SignatureVerificationError), + #[error("安装信任策略拒绝: {0}")] + TrustPolicy(#[from] TrustPolicyError), + #[error("安装已落盘但信任状态尚待恢复: transaction={0}")] + RecoveryRequired(String), + #[error("恢复安装事务失败: {0}")] + Recovery(String), + #[error("升级兼容性检查失败: {0}")] + Upgrade(#[from] UpgradePlanError), + #[error("升级扩大权限,必须显式确认")] + PermissionConfirmationRequired(UpgradePlan), + #[error("读取当前安装失败: {0}")] + InstallationCatalog(#[from] InstallationCatalogError), } impl InstallError { @@ -46,6 +75,28 @@ impl InstallError { Self::StagingWrite(_) => "FINST_STAGING_WRITE", Self::Commit(_) => "FINST_COMMIT", Self::Io(_) => "FINST_IO", + Self::MissingSignature => "FINST_SIGNATURE_MISSING", + Self::UnknownPublisher => "FINST_PUBLISHER_UNKNOWN", + Self::Signature(error) => match error { + SignatureVerificationError::PublisherRevoked => "FINST_PUBLISHER_REVOKED", + SignatureVerificationError::KeyRevoked => "FINST_KEY_REVOKED", + SignatureVerificationError::UnknownKey => "FINST_KEY_UNKNOWN", + SignatureVerificationError::DigestMismatch => "FINST_SIGNATURE_DIGEST", + SignatureVerificationError::InvalidSignature => "FINST_SIGNATURE_INVALID", + _ => "FINST_SIGNATURE_MALFORMED", + }, + Self::TrustPolicy(error) => match error { + TrustPolicyError::UnknownPublisher => "FINST_PUBLISHER_UNKNOWN", + TrustPolicyError::RevokedPublisher => "FINST_PUBLISHER_REVOKED", + TrustPolicyError::Rollback { .. } => "FINST_ROLLBACK", + TrustPolicyError::SameVersionDifferentDigest => "FINST_VERSION_REPLACED", + TrustPolicyError::Store(_) => "FINST_TRUST_STORE", + }, + Self::RecoveryRequired(_) => "FINST_RECOVERY_REQUIRED", + Self::Recovery(_) => "FINST_RECOVERY_FAILED", + Self::Upgrade(_) => "FINST_UPGRADE_INCOMPATIBLE", + Self::PermissionConfirmationRequired(_) => "FINST_PERMISSION_CONFIRMATION", + Self::InstallationCatalog(_) => "FINST_INSTALLED_INVALID", } } @@ -59,6 +110,17 @@ impl InstallError { Self::StagingWrite(_) => Cow::Borrowed("无法写入安装暂存目录"), Self::Commit(_) => Cow::Borrowed("无法原子提交安装"), Self::Io(_) => Cow::Borrowed("插件包读取失败"), + Self::MissingSignature => Cow::Borrowed("插件包缺少签名"), + Self::UnknownPublisher => Cow::Borrowed("插件发布者不受信任"), + Self::Signature(_) => Cow::Borrowed("插件签名未通过验证"), + Self::TrustPolicy(_) => Cow::Borrowed("插件安装被信任或版本策略拒绝"), + Self::RecoveryRequired(_) => Cow::Borrowed("安装需要在下次运行时恢复"), + Self::Recovery(_) => Cow::Borrowed("无法安全恢复上次安装事务"), + Self::Upgrade(_) => Cow::Borrowed("候选包与当前安装不兼容"), + Self::PermissionConfirmationRequired(_) => { + Cow::Borrowed("升级新增或扩大权限,需要显式确认") + } + Self::InstallationCatalog(_) => Cow::Borrowed("当前安装未通过完整性校验"), } } } @@ -69,6 +131,13 @@ pub struct InstalledPackage { pub dir: PathBuf, pub manifest: Manifest, pub meta: InstallMeta, + pub upgrade: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RecoveryReport { + pub finalized: usize, + pub aborted: usize, } /// 插件存储根目录下某插件的安装目录:`//`。 @@ -93,6 +162,229 @@ pub fn install_package( limits: &PackageLimits, ) -> Result { let validated = validate_package(bytes, limits)?; + install_validated(&validated, store, source) +} + +/// Installs a package only after a detached signature verifies against host-owned publisher trust. +pub fn install_trusted_package( + bytes: &[u8], + plugin_store: &Path, + source: &str, + limits: &PackageLimits, + trust_store: &Store, + accept_permission_expansion: bool, +) -> Result { + let validated = validate_package(bytes, limits)?; + let publisher_id = validated.manifest.publisher.id.as_str(); + let trust = trust_store + .trust() + .get(publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let envelope = validated + .files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope( + envelope, + &validated.files, + publisher_id, + &trust.verifier_binding(), + )?; + + let transaction_id = nonce(); + let staging_name = format!(".staging-{transaction_id}"); + let staging = plugin_store.join(&staging_name); + let manifest = validated.manifest.clone(); + let final_dir = install_dir(plugin_store, &manifest.id.0, &manifest.version); + if final_dir.exists() { + return Err(InstallError::AlreadyInstalled { + id: manifest.id.0, + version: manifest.version, + }); + } + let upgrade = match load_highest(plugin_store, &manifest.id.0)? { + Some(current) + if Version::parse(&manifest.version) + .map_err(|error| InstallError::Commit(format!("validated version: {error}")))? + > Version::parse(¤t.manifest.version).map_err(|error| { + InstallError::Commit(format!("installed version passed validation: {error}")) + })? => + { + let plan = plan_upgrade(¤t.manifest, &manifest)?; + if plan.requires_confirmation && !accept_permission_expansion { + return Err(InstallError::PermissionConfirmationRequired(plan)); + } + Some(plan) + } + _ => None, + }; + let parent = final_dir.parent().unwrap_or(plugin_store); + fs::create_dir_all(parent) + .map_err(|error| InstallError::StoreUnavailable(error.to_string()))?; + let meta = match write_staging( + &staging, + &validated, + &manifest, + source, + InstallationTrust::Trusted, + ) { + Ok(meta) => meta, + Err(error) => { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + }; + let version = Version::parse(&manifest.version) + .map_err(|error| InstallError::Commit(format!("validated version: {error}")))?; + let pending = PendingInstallation { + transaction_id: transaction_id.clone(), + publisher_id: publisher_id.to_owned(), + plugin_id: manifest.id.0.clone(), + version, + signed_digest: signable_content_digest(&validated.files), + install_digest: content_digest(&validated.files), + staging_name, + final_relative: format!("{}/{}", manifest.id.0, manifest.version), + created_at: meta.installed_at, + }; + if let Err(error) = trust_store.trust().prepare_install(&pending) { + let _ = fs::remove_dir_all(&staging); + return Err(error.into()); + } + if let Err(error) = fs::rename(&staging, &final_dir) { + let _ = fs::remove_dir_all(&staging); + let _ = trust_store.trust().abort_install(&transaction_id); + return Err(InstallError::Commit(error.to_string())); + } + if trust_store + .trust() + .finalize_install(&transaction_id, meta.installed_at) + .is_err() + { + return Err(InstallError::RecoveryRequired(transaction_id)); + } + + Ok(InstalledPackage { + dir: final_dir, + manifest, + meta, + upgrade, + }) +} + +/// Re-verifies an installed package against current host trust before rollback or execution. +pub fn verify_trusted_installation( + installed: &InstalledInstallation, + trust_store: &Store, +) -> Result<(), InstallError> { + let publisher_id = installed.manifest.publisher.id.as_str(); + let trust = trust_store + .trust() + .get(publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let mut files = BTreeMap::new(); + for name in installed.meta.files.keys() { + let bytes = installed + .file(name) + .ok_or_else(|| InstallError::Commit(format!("installed file missing: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope(envelope, &files, publisher_id, &trust.verifier_binding())?; + Ok(()) +} + +/// Reconciles crash-interrupted trusted installs before a new trusted install begins. +pub fn recover_trusted_installs( + plugin_store: &Path, + trust_store: &Store, +) -> Result { + let mut report = RecoveryReport::default(); + for pending in trust_store + .trust() + .pending_installs() + .map_err(TrustPolicyError::Store)? + { + let staging = plugin_store.join(&pending.staging_name); + let final_dir = plugin_store.join(&pending.final_relative); + match (staging.exists(), final_dir.exists()) { + (true, true) => { + return Err(InstallError::Recovery(format!( + "transaction {} 同时存在 staging 与 final", + pending.transaction_id + ))); + } + (true, false) => { + fs::remove_dir_all(&staging) + .map_err(|error| InstallError::Recovery(error.to_string()))?; + trust_store + .trust() + .abort_install(&pending.transaction_id) + .map_err(TrustPolicyError::Store)?; + report.aborted += 1; + } + (false, false) => { + trust_store + .trust() + .abort_install(&pending.transaction_id) + .map_err(TrustPolicyError::Store)?; + report.aborted += 1; + } + (false, true) => { + let installed = floatile_store::installation::load_exact( + plugin_store, + &pending.plugin_id, + &pending.version.to_string(), + ) + .map_err(|error| InstallError::Recovery(error.to_string()))? + .ok_or_else(|| InstallError::Recovery("final installation 消失".to_owned()))?; + let mut files = BTreeMap::new(); + for name in installed.meta.files.keys() { + let bytes = installed + .file(name) + .ok_or_else(|| InstallError::Recovery(format!("安装文件缺失: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + if content_digest(&files) != pending.install_digest + || signable_content_digest(&files) != pending.signed_digest + { + return Err(InstallError::Recovery( + "恢复安装摘要与 journal 不一致".to_owned(), + )); + } + let trust = trust_store + .trust() + .get(&pending.publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope( + envelope, + &files, + &installed.manifest.publisher.id, + &trust.verifier_binding(), + )?; + trust_store + .trust() + .finalize_install(&pending.transaction_id, now_secs())?; + report.finalized += 1; + } + } + } + Ok(report) +} + +fn install_validated( + validated: &ValidatedPackage, + store: &Path, + source: &str, +) -> Result { let manifest = validated.manifest.clone(); let id = manifest.id.0.clone(); let version = manifest.version.clone(); @@ -107,7 +399,13 @@ pub fn install_package( let staging = store.join(format!(".staging-{}", nonce())); - let meta = match write_staging(&staging, &validated, &manifest, source) { + let meta = match write_staging( + &staging, + validated, + &manifest, + source, + InstallationTrust::Unsigned, + ) { Ok(meta) => meta, Err(e) => { let _ = fs::remove_dir_all(&staging); @@ -124,6 +422,7 @@ pub fn install_package( dir: final_dir, manifest, meta, + upgrade: None, }) } @@ -133,6 +432,7 @@ fn write_staging( validated: &ValidatedPackage, manifest: &Manifest, source: &str, + trust: InstallationTrust, ) -> Result { let installed_at = now_secs(); @@ -150,6 +450,7 @@ fn write_staging( ui_api_version: manifest.ui_api_version.clone(), installed_at, source: source.to_owned(), + trust, files: file_digests, digest: aggregate, }; @@ -213,6 +514,10 @@ fn nonce() -> String { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use ed25519_dalek::{Signer, SigningKey}; + use floatile_core::distribution::{PACKAGE_DIGEST_PAYLOAD_TYPE, dsse_pae, publisher_key_id}; + use floatile_store::trust::TrustState; use std::io::Cursor; fn build_zip(files: &[(&str, &[u8])]) -> Vec { @@ -289,6 +594,81 @@ mod tests { ]) } + fn signed_pkg_bytes(version: &str, signing_key: &SigningKey) -> Vec { + signed_pkg_with_manifest_versions(version, version, signing_key) + } + + fn signed_pkg_with_permissions( + version: &str, + permissions: serde_json::Value, + signing_key: &SigningKey, + ) -> Vec { + let wasm = real_wasm(); + let mut manifest: serde_json::Value = serde_json::from_str(&valid_manifest_json()).unwrap(); + manifest["version"] = serde_json::json!(version); + manifest["permissions"] = permissions; + let manifest = manifest.to_string().into_bytes(); + let ui = valid_ui_ir().into_bytes(); + let mut files = BTreeMap::from([ + ("manifest.json".to_owned(), manifest), + ("ui/widget.ftui".to_owned(), ui), + ("logic/plugin.wasm".to_owned(), wasm), + ]); + let digest = content_digest(&files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &digest)); + let envelope = serde_json::to_vec(&serde_json::json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(digest), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()) + }] + })) + .unwrap(); + files.insert(SIGNATURE_FILE.to_owned(), envelope); + let entries: Vec<_> = files + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())) + .collect(); + build_zip(&entries) + } + + fn signed_pkg_with_manifest_versions( + signed_version: &str, + packaged_version: &str, + signing_key: &SigningKey, + ) -> Vec { + let wasm = real_wasm(); + let mut manifest: serde_json::Value = serde_json::from_str(&valid_manifest_json()).unwrap(); + manifest["version"] = serde_json::json!(signed_version); + let signed_manifest = manifest.to_string().into_bytes(); + let ui = valid_ui_ir().into_bytes(); + let files = BTreeMap::from([ + ("manifest.json".to_owned(), signed_manifest), + ("ui/widget.ftui".to_owned(), ui.clone()), + ("logic/plugin.wasm".to_owned(), wasm.clone()), + ]); + let digest = content_digest(&files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &digest)); + let envelope = serde_json::to_vec(&serde_json::json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(digest), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()) + }] + })) + .unwrap(); + manifest["version"] = serde_json::json!(packaged_version); + let packaged_manifest = manifest.to_string().into_bytes(); + build_zip(&[ + ("manifest.json", packaged_manifest.as_slice()), + ("ui/widget.ftui", ui.as_slice()), + ("logic/plugin.wasm", wasm.as_slice()), + (SIGNATURE_FILE, envelope.as_slice()), + ]) + } + fn temp_store(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("floatile-install-{}-{}", tag, std::process::id())); @@ -424,4 +804,301 @@ mod tests { )); assert_eq!(fs::read_dir(&store).unwrap().count(), 0, "安装失败应无残留"); } + + #[test] + fn trusted_install_verifies_signature_and_advances_watermark() { + let plugin_store = temp_store("trusted"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[21; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + + let installed = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "signed.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + assert!(installed.dir.join(SIGNATURE_FILE).is_file()); + assert_eq!(installed.meta.trust, InstallationTrust::Trusted); + let accepted = trust_store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, Version::parse("1.0.0").unwrap()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn trusted_install_rejects_unsigned_tampered_and_downgrade_without_residue() { + let plugin_store = temp_store("trusted-reject"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[22; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + assert!(matches!( + install_trusted_package( + &valid_pkg_bytes(), + &plugin_store, + "unsigned.floatile", + &Default::default(), + &trust_store, + false + ), + Err(InstallError::MissingSignature) + )); + + let tampered = signed_pkg_with_manifest_versions("1.0.0", "1.0.1", &signing_key); + let tampered_error = install_trusted_package( + &tampered, + &plugin_store, + "tampered.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap_err(); + assert_eq!(tampered_error.code(), "FINST_SIGNATURE_DIGEST"); + + install_trusted_package( + &signed_pkg_bytes("2.0.0", &signing_key), + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + let downgrade = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap_err(); + assert_eq!(downgrade.code(), "FINST_ROLLBACK"); + assert!(!install_dir(&plugin_store, "dev.floatile.clock", "1.0.0").exists()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn trusted_upgrade_requires_explicit_permission_expansion_confirmation() { + let plugin_store = temp_store("trusted-permissions"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[24; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + + let expanded = signed_pkg_with_permissions( + "2.0.0", + serde_json::json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 120, "maxActive": 4 } + }]), + &signing_key, + ); + let rejected = install_trusted_package( + &expanded, + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap_err(); + assert_eq!(rejected.code(), "FINST_PERMISSION_CONFIRMATION"); + assert!(!install_dir(&plugin_store, "dev.floatile.clock", "2.0.0").exists()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + + let installed = install_trusted_package( + &expanded, + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + true, + ) + .unwrap(); + assert!(installed.upgrade.unwrap().requires_confirmation); + + let reduced = signed_pkg_with_permissions( + "3.0.0", + serde_json::json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 30, "maxActive": 1 } + }]), + &signing_key, + ); + let installed = install_trusted_package( + &reduced, + &plugin_store, + "v3.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + assert!(!installed.upgrade.unwrap().requires_confirmation); + } + + #[test] + fn explicit_rollback_rebinds_instance_without_lowering_watermark() { + let root = temp_store("explicit-rollback"); + let plugin_store = root.join("plugins"); + fs::create_dir_all(&plugin_store).unwrap(); + let database = root.join("floatile.db"); + let trust_store = floatile_store::open(&database).unwrap(); + let signing_key = SigningKey::from_bytes(&[25; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + let v1 = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + let v2 = install_trusted_package( + &signed_pkg_bytes("2.0.0", &signing_key), + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + assert!(v1.upgrade.is_none()); + let instance = trust_store + .instances() + .create( + &floatile_core::InstallationRef::from_install_meta(&v2.meta).unwrap(), + &floatile_core::InstanceConfig::empty(), + floatile_core::InstanceDesiredState::Stopped, + 10, + ) + .unwrap(); + + let rolled_back = crate::instance::rollback_instance( + &database, + &plugin_store, + instance.id(), + "1.0.0", + "2.0.0 rendering regression", + 11, + ) + .unwrap(); + assert_eq!(rolled_back.version, "1.0.0"); + let accepted = trust_store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, Version::parse("2.0.0").unwrap()); + } + + #[test] + fn recovery_aborts_staging_and_finalizes_verified_renamed_install() { + let plugin_store = temp_store("trusted-recovery"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[23; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + + let staged = PendingInstallation { + transaction_id: "staged-1".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse("0.5.0").unwrap(), + signed_digest: [1; 32], + install_digest: [2; 32], + staging_name: ".staging-staged-1".to_owned(), + final_relative: "dev.floatile.clock/0.5.0".to_owned(), + created_at: 2, + }; + fs::create_dir_all(plugin_store.join(&staged.staging_name)).unwrap(); + trust_store.trust().prepare_install(&staged).unwrap(); + let report = recover_trusted_installs(&plugin_store, &trust_store).unwrap(); + assert_eq!(report.aborted, 1); + assert!(!plugin_store.join(&staged.staging_name).exists()); + + let package = signed_pkg_bytes("1.0.0", &signing_key); + let installed = install_trusted_package( + &package, + &plugin_store, + "signed.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + let validated = validate_package(&package, &Default::default()).unwrap(); + let renamed = PendingInstallation { + transaction_id: "renamed-1".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse("1.0.0").unwrap(), + signed_digest: signable_content_digest(&validated.files), + install_digest: content_digest(&validated.files), + staging_name: ".staging-renamed-1".to_owned(), + final_relative: "dev.floatile.clock/1.0.0".to_owned(), + created_at: installed.meta.installed_at, + }; + trust_store.trust().prepare_install(&renamed).unwrap(); + let report = recover_trusted_installs(&plugin_store, &trust_store).unwrap(); + assert_eq!(report.finalized, 1); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + assert!(installed.dir.exists()); + } } diff --git a/crates/floatile-cli/src/instance.rs b/crates/floatile-cli/src/instance.rs index f06ddf0..0902f9c 100644 --- a/crates/floatile-cli/src/instance.rs +++ b/crates/floatile-cli/src/instance.rs @@ -3,7 +3,8 @@ use std::path::Path; use floatile_core::{ - InstanceConfig, InstanceDesiredState, InstanceId, PluginInstance, instance::InstanceModelError, + InstanceConfig, InstanceDesiredState, InstanceId, PluginInstance, RollbackPlanError, + instance::InstanceModelError, plan_rollback, }; use floatile_store::installation::{ ConfigValidationError, InstallationCatalogError, InstalledInstallation, load_exact, @@ -71,6 +72,10 @@ pub enum InstanceCommandError { ConcurrentUpdate(u64), #[error("无法创建实例数据库目录: {0}")] DatabaseDirectory(String), + #[error("回滚策略拒绝: {0}")] + Rollback(#[from] RollbackPlanError), + #[error("历史安装信任校验失败: {0}")] + Trust(#[from] crate::install::InstallError), } impl InstanceCommandError { @@ -88,6 +93,8 @@ impl InstanceCommandError { Self::MustBeStopped(_) => "FINSTANCE_MUST_BE_STOPPED", Self::ConcurrentUpdate(_) => "FINSTANCE_CONCURRENT_UPDATE", Self::DatabaseDirectory(_) => "FINSTANCE_DATABASE_DIRECTORY", + Self::Rollback(_) => "FINSTANCE_ROLLBACK_REJECTED", + Self::Trust(error) => error.code(), } } } @@ -197,6 +204,54 @@ pub fn delete_instance( Ok(InstanceView::from(&instance)) } +/// Rebinds a stopped instance to a trusted, verified historical installation. +pub fn rollback_instance( + database: &Path, + plugin_store: &Path, + instance_id: InstanceId, + target_version: &str, + reason: &str, + unix_ts: u64, +) -> Result { + if reason.is_empty() || reason.len() > 512 { + return Err(InstanceCommandError::InvalidArguments( + "rollback 需要 1..=512 字节的 --reason".to_owned(), + )); + } + let store = open_database(database)?; + let instance = require_instance(&store, instance_id)?; + require_stopped(&instance)?; + let current = load_reference(plugin_store, instance.installation())?.ok_or_else(|| { + InstanceCommandError::InstallationMissing { + id: instance.installation().plugin().0.clone(), + version: instance.installation().version().to_owned(), + } + })?; + let target = load_exact( + plugin_store, + &instance.installation().plugin().0, + target_version, + )? + .ok_or_else(|| InstanceCommandError::InstallationMissing { + id: instance.installation().plugin().0.clone(), + version: target_version.to_owned(), + })?; + plan_rollback(¤t.manifest, &target.manifest)?; + validate_config(&target, instance.config())?; + crate::install::verify_trusted_installation(&target, &store)?; + let target_ref = target.reference()?; + if !store.instances().rollback_installation( + instance_id, + instance.installation(), + &target_ref, + reason, + unix_ts.max(instance.updated_at()), + )? { + return Err(InstanceCommandError::ConcurrentUpdate(instance_id.0)); + } + get_from_store(&store, instance_id) +} + fn validate_config( installation: &InstalledInstallation, config: &InstanceConfig, @@ -324,6 +379,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index b8ff529..0c08715 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -16,16 +16,20 @@ pub mod preview; pub mod project; pub mod run; pub mod test; +pub mod trust; pub use build::{BuildError, build_project, package}; pub use check::{CheckError, CheckPhases, CheckReport, CheckWarning, check_project}; pub use conformance::{ConformanceError, ConformanceReport, lifecycle_report}; pub use dev::{BuildStatus, build_once, dev_loop, ensure_project}; pub use inspect::{InspectError, InspectReport, inspect_package, inspect_package_bytes}; -pub use install::{InstallError, InstalledPackage, install_dir, install_package}; +pub use install::{ + InstallError, InstalledPackage, RecoveryReport, install_dir, install_package, + install_trusted_package, recover_trusted_installs, verify_trusted_installation, +}; pub use instance::{ InstanceCommandError, InstanceView, configure_instance, create_instance, delete_instance, - get_instance, list_instances, set_instance_desired_state, + get_instance, list_instances, rollback_instance, set_instance_desired_state, }; pub use output::{CommandErrorReport, CommandWarning, OUTPUT_SCHEMA_VERSION}; pub use package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index e755e77..5e2e21f 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -6,15 +6,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, - preview, project, run, test, + preview, project, run, test, trust, }; -use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId}; +use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId, PermissionChangeKind}; fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -25,6 +25,7 @@ fn main() -> ExitCode { "inspect" => cmd_inspect(&args[2..]), "build" => cmd_build(&args[2..]), "install" => cmd_install(&args[2..]), + "trust" => cmd_trust(&args[2..]), "instance" => cmd_instance(&args[2..]), "dev" => cmd_dev(&args[2..]), "test" => cmd_test(&args[2..]), @@ -39,6 +40,52 @@ fn main() -> ExitCode { } } +fn cmd_trust(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + let positionals = match author_positionals(args, &["--db"], &[], 3) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FTRUST_ARGUMENT", &detail, json, true), + }; + let database = match opts_database(args) { + Ok(database) => database, + Err(detail) => return render_basic_error("FTRUST_ARGUMENT", &detail, json, true), + }; + let result = match positionals.as_slice() { + ["show", publisher] => trust::show(&database, publisher), + ["add-key", publisher, public_key] => { + trust::add_key(&database, publisher, public_key, unix_timestamp()) + } + ["revoke-key", publisher, public_key] => { + trust::revoke_key(&database, publisher, public_key, unix_timestamp()) + } + ["revoke-publisher", publisher] => { + trust::revoke_publisher(&database, publisher, unix_timestamp()) + } + _ => { + return render_basic_error( + "FTRUST_ARGUMENT", + "用法: trust [public-key-hex] [--db PATH]", + json, + true, + ); + } + }; + match result { + Ok(view) => { + if json { + println!("{}", serialize_json(&view)); + } else { + println!("trust: {} state={}", view.publisher_id, view.state); + for key in view.keys { + println!(" key={} state={}", key.key_id, key.state); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_conformance(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); if let Err(detail) = author_positionals(args, &[], &[], 0) { @@ -312,6 +359,21 @@ fn cmd_instance(args: &[String]) -> ExitCode { }), "delete" => instance_id(&options) .and_then(|id| instance::delete_instance(&database, id).map(InstanceOutput::Deleted)), + "rollback" => instance_id(&options).and_then(|id| { + let version = options.version.as_deref().ok_or_else(|| { + instance::InstanceCommandError::InvalidArguments( + "rollback 需要 --version".to_owned(), + ) + })?; + let reason = options.reason.as_deref().ok_or_else(|| { + instance::InstanceCommandError::InvalidArguments( + "rollback 需要 --reason".to_owned(), + ) + })?; + let store = options.plugin_store()?; + instance::rollback_instance(&database, &store, id, version, reason, unix_timestamp()) + .map(InstanceOutput::One) + }), _ => { print_instance_usage(); return ExitCode::from(2); @@ -450,6 +512,7 @@ struct InstanceCliOptions { config_file: Option, database: Option, store: Option, + reason: Option, json: bool, start: bool, } @@ -463,7 +526,7 @@ impl InstanceCliOptions { "--json" => parsed.json = true, "--no-interactive" => {} "--start" => parsed.start = true, - "--version" | "--config" | "--config-file" | "--db" | "--store" => { + "--version" | "--config" | "--config-file" | "--db" | "--store" | "--reason" => { let name = args[index].as_str(); index += 1; let value = args.get(index).ok_or_else(|| format!("{name} 缺少值"))?; @@ -475,6 +538,8 @@ impl InstanceCliOptions { parsed.config_file = Some(PathBuf::from(value)); } else if name == "--db" { parsed.database = Some(PathBuf::from(value)); + } else if name == "--reason" { + parsed.reason = Some(value.clone()); } else { parsed.store = Some(PathBuf::from(value)); } @@ -555,7 +620,7 @@ fn unix_timestamp() -> u64 { fn print_instance_usage() { eprintln!( - "用法:\n floatile instance create --version [--config |--config-file ] [--start] [--db PATH] [--store PATH] [--json]\n floatile instance [instance-id] [--db PATH] [--json]\n floatile instance configure (--config |--config-file ) [--db PATH] [--store PATH] [--json]" + "用法:\n floatile instance create --version [--config |--config-file ] [--start] [--db PATH] [--store PATH] [--json]\n floatile instance [instance-id] [--db PATH] [--json]\n floatile instance configure (--config |--config-file ) [--db PATH] [--store PATH] [--json]\n floatile instance rollback --version --reason [--db PATH] [--store PATH] [--json]" ); } @@ -823,7 +888,16 @@ fn cmd_test(args: &[String]) -> ExitCode { fn cmd_install(args: &[String]) -> ExitCode { let json = args.iter().any(|a| a == "--json"); - let positionals = match author_positionals(args, &["--store"], &[], 1) { + let require_trusted = args.iter().any(|argument| argument == "--require-trusted"); + let accept_permissions = args + .iter() + .any(|argument| argument == "--accept-permissions"); + let positionals = match author_positionals( + args, + &["--store", "--db"], + &["--require-trusted", "--accept-permissions"], + 1, + ) { Ok(positionals) => positionals, Err(detail) => return render_basic_error("FINST_ARGUMENT", &detail, json, true), }; @@ -844,8 +918,40 @@ fn cmd_install(args: &[String]) -> ExitCode { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "plugin.floatile".to_owned()); - match install::install_package(&bytes, &store, &source, &package::PackageLimits::default()) { + let result = if require_trusted { + let database = match opts_database(args) { + Ok(database) => database, + Err(message) => return render_basic_error("FINST_ARGUMENT", &message, json, true), + }; + if let Some(parent) = database.parent() + && !parent.as_os_str().is_empty() + && std::fs::create_dir_all(parent).is_err() + { + return render_basic_error("FINST_TRUST_STORE", "无法创建信任数据库目录", json, false); + } + let trust_store = match floatile_store::open(&database) { + Ok(store) => store, + Err(_) => { + return render_basic_error("FINST_TRUST_STORE", "无法打开信任数据库", json, false); + } + }; + if let Err(error) = install::recover_trusted_installs(&store, &trust_store) { + return render_basic_error(error.code(), error.public_detail().as_ref(), json, false); + } + install::install_trusted_package( + &bytes, + &store, + &source, + &package::PackageLimits::default(), + &trust_store, + accept_permissions, + ) + } else { + install::install_package(&bytes, &store, &source, &package::PackageLimits::default()) + }; + match result { Ok(installed) => { + let upgrade = installed.upgrade.as_ref().map(upgrade_json); if json { println!( "{}", @@ -857,24 +963,78 @@ fn cmd_install(args: &[String]) -> ExitCode { "version": installed.meta.version, "dir": installed.dir.display().to_string(), "digest": installed.meta.digest, + "trust": if require_trusted { "trusted" } else { "unsigned" }, + "upgrade": upgrade, } }) ); } else { println!( - "已安装 {} {} -> {} (digest {})", + "已安装 {} {} -> {} (digest {}, trust={})", installed.manifest.id.0, installed.meta.version, installed.dir.display(), &installed.meta.digest[..12], + if require_trusted { + "trusted" + } else { + "unsigned" + }, ); + if let Some(plan) = &installed.upgrade { + println!( + " upgrade {} -> {} permission_confirmation={}", + plan.current_version, plan.candidate_version, plan.requires_confirmation + ); + for change in &plan.permissions { + println!(" {} {:?}", change.capability.name(), change.kind); + } + } } ExitCode::SUCCESS } - Err(error) => render_basic_error(error.code(), error.public_detail().as_ref(), json, false), + Err(error) => { + if let install::InstallError::PermissionConfirmationRequired(plan) = &error { + let detail = error.public_detail(); + if json { + let report = CommandErrorReport::new( + error.code(), + detail.as_ref(), + serde_json::json!({ "upgrade": upgrade_json(plan) }), + ); + eprintln!("{}", serialize_json(&report)); + } else { + eprintln!("命令失败: code={} detail={detail}", error.code()); + for change in &plan.permissions { + eprintln!(" {} {:?}", change.capability.name(), change.kind); + } + } + ExitCode::FAILURE + } else { + render_basic_error(error.code(), error.public_detail().as_ref(), json, false) + } + } } } +fn upgrade_json(plan: &floatile_core::UpgradePlan) -> serde_json::Value { + serde_json::json!({ + "currentVersion": plan.current_version.to_string(), + "candidateVersion": plan.candidate_version.to_string(), + "requiresConfirmation": plan.requires_confirmation, + "permissions": plan.permissions.iter().map(|change| serde_json::json!({ + "capability": change.capability, + "change": match change.kind { + PermissionChangeKind::Added => "added", + PermissionChangeKind::Removed => "removed", + PermissionChangeKind::Expanded => "expanded", + PermissionChangeKind::Reduced => "reduced", + PermissionChangeKind::Unchanged => "unchanged", + } + })).collect::>() + }) +} + fn cmd_preview(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let positionals = match author_positionals(args, &["--duration-ms"], &[], 1) { @@ -1046,3 +1206,16 @@ fn opts_store(args: &[String]) -> Result { } Err("未指定插件存储:请用 `--store PATH` 或设置环境变量 FLOATTILE_PLUGIN_DIR".to_owned()) } + +/// 解析宿主持久化数据库:优先 `--db PATH`,其次环境变量,最后平台数据目录。 +fn opts_database(args: &[String]) -> Result { + if let Some(path) = option_path(args, "--db") { + return Ok(path); + } + if let Some(path) = std::env::var_os("FLOATTILE_DB_PATH").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + floatile_platform::data_dir() + .map(|path| path.join("layout.db")) + .map_err(|error| error.to_string()) +} diff --git a/crates/floatile-cli/src/trust.rs b/crates/floatile-cli/src/trust.rs new file mode 100644 index 0000000..34e97c6 --- /dev/null +++ b/crates/floatile-cli/src/trust.rs @@ -0,0 +1,197 @@ +//! Host-owned publisher trust administration for PP-M8. + +use std::path::Path; + +use floatile_core::install::hex_decode; +use floatile_store::trust::{PublisherTrustRecord, TrustState}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum TrustCommandError { + #[error("publisher id 或 Ed25519 public key 参数非法")] + InvalidArgument, + #[error("无法创建数据库目录: {0}")] + DatabaseDirectory(String), + #[error("trust store 失败: {0}")] + Store(#[from] floatile_store::StoreError), + #[error("publisher trust 不存在")] + NotFound, +} + +impl TrustCommandError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidArgument => "FTRUST_ARGUMENT", + Self::DatabaseDirectory(_) => "FTRUST_DATABASE", + Self::Store(_) => "FTRUST_STORE", + Self::NotFound => "FTRUST_NOT_FOUND", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrustView { + pub publisher_id: String, + pub state: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrustKeyView { + pub key_id: String, + pub state: String, +} + +pub fn add_key( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + updated_at: u64, +) -> Result { + set_key_state( + database, + publisher_id, + public_key_hex, + TrustState::Active, + updated_at, + ) +} + +pub fn revoke_key( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + updated_at: u64, +) -> Result { + set_key_state( + database, + publisher_id, + public_key_hex, + TrustState::Revoked, + updated_at, + ) +} + +pub fn revoke_publisher( + database: &Path, + publisher_id: &str, + updated_at: u64, +) -> Result { + let store = open_database(database)?; + if !store + .trust() + .set_publisher_state(publisher_id, TrustState::Revoked, updated_at)? + { + return Err(TrustCommandError::NotFound); + } + view(&store, publisher_id) +} + +pub fn show(database: &Path, publisher_id: &str) -> Result { + let store = open_database(database)?; + view(&store, publisher_id) +} + +fn set_key_state( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + state: TrustState, + updated_at: u64, +) -> Result { + let public_key: [u8; 32] = hex_decode(public_key_hex) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(TrustCommandError::InvalidArgument)?; + let store = open_database(database)?; + store + .trust() + .upsert_key(publisher_id, public_key, state, updated_at)?; + view(&store, publisher_id) +} + +fn open_database(database: &Path) -> Result { + if let Some(parent) = database.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .map_err(|error| TrustCommandError::DatabaseDirectory(error.to_string()))?; + } + floatile_store::open(database).map_err(Into::into) +} + +fn view(store: &floatile_store::Store, publisher_id: &str) -> Result { + store + .trust() + .get(publisher_id)? + .map(TrustView::from) + .ok_or(TrustCommandError::NotFound) +} + +impl From for TrustView { + fn from(record: PublisherTrustRecord) -> Self { + Self { + publisher_id: record.publisher_id, + state: state_name(record.state).to_owned(), + keys: record + .keys + .into_iter() + .map(|key| TrustKeyView { + key_id: key.key_id, + state: state_name(key.state).to_owned(), + }) + .collect(), + } + } +} + +fn state_name(state: TrustState) -> &'static str { + match state { + TrustState::Active => "active", + TrustState::Revoked => "revoked", + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn database(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("floatile-trust-{tag}-{}.db", std::process::id())) + } + + #[test] + fn add_show_and_revoke_never_expose_public_key_bytes() { + let database = database("lifecycle"); + let _ = std::fs::remove_file(&database); + let key = "07".repeat(32); + let added = add_key(&database, "dev.floatile", &key, 1).unwrap(); + assert_eq!(added.state, "active"); + assert_eq!(added.keys[0].state, "active"); + assert!(!serde_json::to_string(&added).unwrap().contains(&key)); + + let revoked = revoke_key(&database, "dev.floatile", &key, 2).unwrap(); + assert_eq!(revoked.keys[0].state, "revoked"); + let publisher = revoke_publisher(&database, "dev.floatile", 3).unwrap(); + assert_eq!(publisher.state, "revoked"); + assert_eq!(show(&database, "dev.floatile").unwrap(), publisher); + let _ = std::fs::remove_file(&database); + } + + #[test] + fn rejects_noncanonical_key_length_and_unknown_publisher() { + let database = database("invalid"); + let _ = std::fs::remove_file(&database); + assert!(matches!( + add_key(&database, "dev.floatile", "00", 1), + Err(TrustCommandError::InvalidArgument) + )); + assert!(matches!( + show(&database, "unknown.publisher"), + Err(TrustCommandError::NotFound) + )); + let _ = std::fs::remove_file(&database); + } +} diff --git a/crates/floatile-core/Cargo.toml b/crates/floatile-core/Cargo.toml index 2c8669d..a47934a 100644 --- a/crates/floatile-core/Cargo.toml +++ b/crates/floatile-core/Cargo.toml @@ -11,6 +11,8 @@ publish = false workspace = true [dependencies] +base64 = { workspace = true } +ed25519-dalek = { workspace = true } jsonschema = { workspace = true } schemars = { workspace = true } semver = { workspace = true } diff --git a/crates/floatile-core/src/distribution.rs b/crates/floatile-core/src/distribution.rs new file mode 100644 index 0000000..2fe44b3 --- /dev/null +++ b/crates/floatile-core/src/distribution.rs @@ -0,0 +1,818 @@ +//! PP-M8 package signature verification and publisher trust binding. +//! +//! The package remains untrusted input throughout this module. A valid signature proves that the +//! signable package bytes were authenticated by a host-trusted publisher key; it never grants a +//! capability and it does not replace install-time path or resource validation. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::install::{content_digest, hex_encode}; +use crate::{CapabilityId, CapabilityParams, Manifest, parse_capability_params}; + +pub const SIGNATURE_FILE: &str = "signature.json"; +pub const PACKAGE_DIGEST_PAYLOAD_TYPE: &str = "application/vnd.floatile.package-digest.v1"; +pub const MAX_SIGNATURE_ENVELOPE_BYTES: usize = 16 * 1024; +pub const MAX_PACKAGE_SIGNATURES: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustedPublisherState { + Active, + Revoked, +} + +/// Host-owned trust binding. Keys are raw Ed25519 public-key bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedPublisher { + pub publisher_id: String, + pub state: TrustedPublisherState, + pub keys: Vec<[u8; 32]>, + /// Host-revoked key identifiers retained to distinguish revocation from an unknown key. + pub revoked_key_ids: Vec, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SignatureVerificationError { + #[error("signature envelope exceeds the size limit")] + EnvelopeTooLarge, + #[error("signature envelope is malformed")] + MalformedEnvelope, + #[error("signature payload type is unsupported")] + UnsupportedPayloadType, + #[error("signature envelope contains an invalid signature count")] + InvalidSignatureCount, + #[error("signed package digest does not match package contents")] + DigestMismatch, + #[error("package publisher does not match the trust binding")] + PublisherMismatch, + #[error("package publisher is revoked")] + PublisherRevoked, + #[error("package signing key is revoked")] + KeyRevoked, + #[error("signature envelope does not reference a trusted publisher key")] + UnknownKey, + #[error("package signature is invalid")] + InvalidSignature, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionChangeKind { + Added, + Removed, + Expanded, + Reduced, + Unchanged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionChange { + pub capability: CapabilityId, + pub kind: PermissionChangeKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradePlan { + pub current_version: semver::Version, + pub candidate_version: semver::Version, + pub permissions: Vec, + pub requires_confirmation: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RollbackPlan { + pub current_version: semver::Version, + pub target_version: semver::Version, + pub permissions: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum UpgradePlanError { + #[error("upgrade candidate changes plugin id")] + PluginMismatch, + #[error("upgrade candidate changes publisher")] + PublisherMismatch, + #[error("upgrade candidate version must be newer")] + VersionNotNewer, + #[error("upgrade candidate decreases storage migration version")] + StorageMigrationRollback, + #[error("upgrade manifest contains invalid permission: {0}")] + InvalidPermission(String), +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RollbackPlanError { + #[error("rollback target changes plugin id")] + PluginMismatch, + #[error("rollback target changes publisher")] + PublisherMismatch, + #[error("rollback target version must be older")] + VersionNotOlder, + #[error("rollback target cannot read the current storage migration")] + StorageMigrationIncompatible, + #[error("rollback would restore previously removed or reduced permissions")] + PermissionExpansion, + #[error("rollback manifest contains invalid permission: {0}")] + InvalidPermission(String), +} + +/// Builds a deterministic permission and compatibility plan for an in-place upgrade. +pub fn plan_upgrade( + current: &Manifest, + candidate: &Manifest, +) -> Result { + if current.id != candidate.id { + return Err(UpgradePlanError::PluginMismatch); + } + if current.publisher.id != candidate.publisher.id { + return Err(UpgradePlanError::PublisherMismatch); + } + let current_version = semver::Version::parse(¤t.version) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + let candidate_version = semver::Version::parse(&candidate.version) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + if candidate_version <= current_version { + return Err(UpgradePlanError::VersionNotNewer); + } + let current_migration = current + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + let candidate_migration = candidate + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + if candidate_migration < current_migration { + return Err(UpgradePlanError::StorageMigrationRollback); + } + + let permissions = + permission_changes(current, candidate).map_err(UpgradePlanError::InvalidPermission)?; + let requires_confirmation = permissions.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added | PermissionChangeKind::Expanded + ) + }); + Ok(UpgradePlan { + current_version, + candidate_version, + permissions, + requires_confirmation, + }) +} + +/// Plans an explicit rollback without lowering anti-rollback state or restoring old authority. +pub fn plan_rollback( + current: &Manifest, + target: &Manifest, +) -> Result { + if current.id != target.id { + return Err(RollbackPlanError::PluginMismatch); + } + if current.publisher.id != target.publisher.id { + return Err(RollbackPlanError::PublisherMismatch); + } + let current_version = semver::Version::parse(¤t.version) + .map_err(|error| RollbackPlanError::InvalidPermission(error.to_string()))?; + let target_version = semver::Version::parse(&target.version) + .map_err(|error| RollbackPlanError::InvalidPermission(error.to_string()))?; + if target_version >= current_version { + return Err(RollbackPlanError::VersionNotOlder); + } + let current_migration = current + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + let target_migration = target + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + if target_migration != current_migration { + return Err(RollbackPlanError::StorageMigrationIncompatible); + } + let permissions = + permission_changes(current, target).map_err(RollbackPlanError::InvalidPermission)?; + if permissions.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added | PermissionChangeKind::Expanded + ) + }) { + return Err(RollbackPlanError::PermissionExpansion); + } + Ok(RollbackPlan { + current_version, + target_version, + permissions, + }) +} + +fn permission_changes( + current: &Manifest, + candidate: &Manifest, +) -> Result, String> { + let current_permissions = parsed_permissions(current).map_err(|error| error.to_string())?; + let candidate_permissions = parsed_permissions(candidate).map_err(|error| error.to_string())?; + let capabilities: BTreeSet<_> = current_permissions + .keys() + .chain(candidate_permissions.keys()) + .copied() + .collect(); + let mut permissions = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + let kind = match ( + current_permissions.get(&capability), + candidate_permissions.get(&capability), + ) { + (None, Some(_)) => PermissionChangeKind::Added, + (Some(_), None) => PermissionChangeKind::Removed, + (Some(current), Some(candidate)) if current == candidate => { + PermissionChangeKind::Unchanged + } + (Some(current), Some(candidate)) if params_within(candidate, current) => { + PermissionChangeKind::Reduced + } + (Some(_), Some(_)) => PermissionChangeKind::Expanded, + (None, None) => continue, + }; + permissions.push(PermissionChange { capability, kind }); + } + Ok(permissions) +} + +fn parsed_permissions( + manifest: &Manifest, +) -> Result>, UpgradePlanError> { + manifest + .permissions + .iter() + .map(|permission| { + let capability = CapabilityId::from_name(&permission.capability).ok_or_else(|| { + UpgradePlanError::InvalidPermission(permission.capability.clone()) + })?; + let params = parse_capability_params(capability, permission.params.as_ref()) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + Ok((capability, params)) + }) + .collect() +} + +fn params_within(candidate: &Option, current: &Option) -> bool { + match (candidate, current) { + (None, None) => true, + ( + Some(CapabilityParams::Storage { + keys: candidate_keys, + max_bytes: candidate_bytes, + }), + Some(CapabilityParams::Storage { + keys: current_keys, + max_bytes: current_bytes, + }), + ) => key_scope_within(candidate_keys, current_keys) && candidate_bytes <= current_bytes, + ( + Some(CapabilityParams::Timer { + max_per_minute: candidate_rate, + max_active: candidate_active, + }), + Some(CapabilityParams::Timer { + max_per_minute: current_rate, + max_active: current_active, + }), + ) => candidate_rate <= current_rate && candidate_active <= current_active, + ( + Some(CapabilityParams::Metrics { + sample_rate_hz: candidate_rate, + }), + Some(CapabilityParams::Metrics { + sample_rate_hz: current_rate, + }), + ) => candidate_rate <= current_rate, + ( + Some(CapabilityParams::Network { + origins: candidate_origins, + max_requests_per_minute: candidate_requests, + max_response_bytes: candidate_bytes, + max_timeout_ms: candidate_timeout, + }), + Some(CapabilityParams::Network { + origins: current_origins, + max_requests_per_minute: current_requests, + max_response_bytes: current_bytes, + max_timeout_ms: current_timeout, + }), + ) => { + candidate_origins + .iter() + .all(|origin| current_origins.contains(origin)) + && candidate_requests <= current_requests + && candidate_bytes <= current_bytes + && candidate_timeout <= current_timeout + } + _ => false, + } +} + +fn key_scope_within(candidate: &[String], current: &[String]) -> bool { + if current.is_empty() { + return true; + } + !candidate.is_empty() && candidate.iter().all(|key| current.contains(key)) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SignatureEnvelope { + #[serde(rename = "payloadType")] + payload_type: String, + payload: String, + signatures: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct EnvelopeSignature { + keyid: String, + sig: String, +} + +/// Computes the digest authenticated by `signature.json`. +/// +/// The detached envelope is the sole excluded root file. Install integrity must separately use +/// `content_digest`, which includes every installed ordinary file. +pub fn signable_content_digest(files: &BTreeMap>) -> [u8; 32] { + let signable = files + .iter() + .filter(|(path, _)| path.as_str() != SIGNATURE_FILE) + .map(|(path, bytes)| (path.clone(), bytes.clone())) + .collect(); + content_digest(&signable) +} + +/// Returns the lowercase SHA-256 key identifier for raw Ed25519 public-key bytes. +pub fn publisher_key_id(public_key: &[u8; 32]) -> String { + hex_encode(&Sha256::digest(public_key)) +} + +/// DSSE v1 pre-authentication encoding. +pub fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec { + format!( + "DSSEv1 {} {payload_type} {} ", + payload_type.len(), + payload.len() + ) + .into_bytes() + .into_iter() + .chain(payload.iter().copied()) + .collect() +} + +/// Verifies a detached package signature against a host-owned publisher trust binding. +pub fn verify_signature_envelope( + envelope_json: &[u8], + files: &BTreeMap>, + manifest_publisher_id: &str, + trusted_publisher: &TrustedPublisher, +) -> Result<(), SignatureVerificationError> { + if envelope_json.len() > MAX_SIGNATURE_ENVELOPE_BYTES { + return Err(SignatureVerificationError::EnvelopeTooLarge); + } + if manifest_publisher_id != trusted_publisher.publisher_id { + return Err(SignatureVerificationError::PublisherMismatch); + } + if trusted_publisher.state == TrustedPublisherState::Revoked { + return Err(SignatureVerificationError::PublisherRevoked); + } + + let envelope: SignatureEnvelope = serde_json::from_slice(envelope_json) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if envelope.payload_type != PACKAGE_DIGEST_PAYLOAD_TYPE { + return Err(SignatureVerificationError::UnsupportedPayloadType); + } + if envelope.signatures.is_empty() || envelope.signatures.len() > MAX_PACKAGE_SIGNATURES { + return Err(SignatureVerificationError::InvalidSignatureCount); + } + + let payload = decode_canonical_base64(&envelope.payload)?; + if payload.len() != 32 { + return Err(SignatureVerificationError::MalformedEnvelope); + } + if payload.as_slice() != signable_content_digest(files) { + return Err(SignatureVerificationError::DigestMismatch); + } + let pae = dsse_pae(&envelope.payload_type, &payload); + if envelope + .signatures + .iter() + .any(|signature| trusted_publisher.revoked_key_ids.contains(&signature.keyid)) + { + return Err(SignatureVerificationError::KeyRevoked); + } + let mut matched_trusted_key = false; + + for signature in &envelope.signatures { + for public_key in &trusted_publisher.keys { + if signature.keyid != publisher_key_id(public_key) { + continue; + } + matched_trusted_key = true; + let signature_bytes = decode_canonical_base64(&signature.sig)?; + let signature_bytes: [u8; 64] = signature_bytes + .try_into() + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + let verifying_key = VerifyingKey::from_bytes(public_key) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if verifying_key + .verify_strict(&pae, &Signature::from_bytes(&signature_bytes)) + .is_ok() + { + return Ok(()); + } + } + } + + if matched_trusted_key { + Err(SignatureVerificationError::InvalidSignature) + } else { + Err(SignatureVerificationError::UnknownKey) + } +} + +fn decode_canonical_base64(value: &str) -> Result, SignatureVerificationError> { + let decoded = STANDARD + .decode(value) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if STANDARD.encode(&decoded) != value { + return Err(SignatureVerificationError::MalformedEnvelope); + } + Ok(decoded) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + use serde_json::json; + + use super::*; + use crate::install::hex_decode; + + fn package_files() -> BTreeMap> { + BTreeMap::from([ + ("logic/plugin.wasm".to_owned(), vec![0, 97, 115, 109]), + ( + "manifest.json".to_owned(), + br#"{"id":"dev.floatile.clock"}"#.to_vec(), + ), + (SIGNATURE_FILE.to_owned(), b"detached envelope".to_vec()), + ]) + } + + fn signed_envelope(files: &BTreeMap>, signing_key: &SigningKey) -> Vec { + let payload = signable_content_digest(files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &payload)); + serde_json::to_vec(&json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(payload), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()), + }] + })) + .unwrap() + } + + fn trusted(signing_key: &SigningKey) -> TrustedPublisher { + TrustedPublisher { + publisher_id: "dev.floatile".to_owned(), + state: TrustedPublisherState::Active, + keys: vec![signing_key.verifying_key().to_bytes()], + revoked_key_ids: Vec::new(), + } + } + + #[test] + fn verifies_trusted_dsse_signature() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[7; 32]); + let envelope = signed_envelope(&files, &signing_key); + + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &trusted(&signing_key)), + Ok(()) + ); + } + + #[test] + fn signature_file_is_detached_but_other_files_are_authenticated() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[8; 32]); + let envelope = signed_envelope(&files, &signing_key); + + let mut changed_envelope_file = files.clone(); + changed_envelope_file.insert(SIGNATURE_FILE.to_owned(), b"replacement".to_vec()); + assert_eq!( + verify_signature_envelope( + &envelope, + &changed_envelope_file, + "dev.floatile", + &trusted(&signing_key) + ), + Ok(()) + ); + + let mut changed_wasm = files.clone(); + changed_wasm.insert("logic/plugin.wasm".to_owned(), vec![1]); + assert_eq!( + verify_signature_envelope( + &envelope, + &changed_wasm, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::DigestMismatch) + ); + } + + #[test] + fn rejects_unknown_key_invalid_signature_and_publisher_states() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[9; 32]); + let envelope = signed_envelope(&files, &signing_key); + let other_key = SigningKey::from_bytes(&[10; 32]); + + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &trusted(&other_key)), + Err(SignatureVerificationError::UnknownKey) + ); + assert_eq!( + verify_signature_envelope(&envelope, &files, "other.publisher", &trusted(&signing_key)), + Err(SignatureVerificationError::PublisherMismatch) + ); + + let mut revoked = trusted(&signing_key); + revoked.state = TrustedPublisherState::Revoked; + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &revoked), + Err(SignatureVerificationError::PublisherRevoked) + ); + + let mut revoked_key = trusted(&signing_key); + revoked_key.keys.clear(); + revoked_key + .revoked_key_ids + .push(publisher_key_id(signing_key.verifying_key().as_bytes())); + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &revoked_key), + Err(SignatureVerificationError::KeyRevoked) + ); + + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["signatures"][0]["sig"] = json!(STANDARD.encode([0; 64])); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::InvalidSignature) + ); + } + + #[test] + fn rejects_domain_changes_and_unbounded_or_noncanonical_envelopes() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[11; 32]); + let envelope = signed_envelope(&files, &signing_key); + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["payloadType"] = json!("application/octet-stream"); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::UnsupportedPayloadType) + ); + + assert_eq!( + verify_signature_envelope( + &vec![b' '; MAX_SIGNATURE_ENVELOPE_BYTES + 1], + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::EnvelopeTooLarge) + ); + + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["unexpected"] = json!(true); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::MalformedEnvelope) + ); + } + + #[test] + fn accepts_rfc8032_ed25519_test_vector() { + // RFC 8032 section 7.1, test 1: empty message. + let public_key: [u8; 32] = hex_decode( + "d75a980182b10ab7d54bfed3c964073a\ + 0ee172f3daa62325af021a68f707511a", + ) + .unwrap() + .try_into() + .unwrap(); + let signature: [u8; 64] = hex_decode( + "e5564300c360ac729086e2cc806e828a\ + 84877f1eb8e5d974d873e06522490155\ + 5fb8821590a33bacc61e39701cf9b46b\ + d25bf5f0595bbe24655141438e7a100b", + ) + .unwrap() + .try_into() + .unwrap(); + + assert!( + VerifyingKey::from_bytes(&public_key) + .unwrap() + .verify_strict(b"", &Signature::from_bytes(&signature)) + .is_ok() + ); + } + + #[test] + fn pae_matches_dsse_v1_encoding() { + assert_eq!( + dsse_pae("text/plain", b"hello"), + b"DSSEv1 10 text/plain 5 hello" + ); + } + + fn upgrade_manifest( + version: &str, + permissions: serde_json::Value, + migration_version: u64, + ) -> Manifest { + serde_json::from_value(json!({ + "manifestVersion": 1, + "id": "dev.floatile.clock", + "name": "Clock", + "version": version, + "publisher": { "id": "dev.floatile", "name": "Floatile" }, + "engineApiVersion": "1.0.0", + "uiApiVersion": "1.0.0", + "type": "widget", + "entrypoints": { "ui": "ui/widget.ftui", "logic": "logic/plugin.wasm" }, + "sizes": { + "default": { "width": 240.0, "height": 120.0 }, + "min": { "width": 100.0, "height": 60.0 }, + "max": { "width": 800.0, "height": 600.0 }, + "resizable": true + }, + "permissions": permissions, + "storage": { "migration_version": migration_version } + })) + .unwrap() + } + + #[test] + fn upgrade_plan_requires_confirmation_for_added_or_expanded_permissions() { + let current = upgrade_manifest( + "1.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 10, "maxActive": 2 } + }]), + 1, + ); + let candidate = upgrade_manifest( + "2.0.0", + json!([ + { + "capability": "timer:schedule", + "params": { "maxPerMinute": 20, "maxActive": 2 } + }, + { "capability": "theme:subscribe" } + ]), + 2, + ); + let plan = plan_upgrade(¤t, &candidate).unwrap(); + assert!(plan.requires_confirmation); + assert_eq!( + plan.permissions, + vec![ + PermissionChange { + capability: CapabilityId::TimerSchedule, + kind: PermissionChangeKind::Expanded, + }, + PermissionChange { + capability: CapabilityId::ThemeSubscribe, + kind: PermissionChangeKind::Added, + } + ] + ); + } + + #[test] + fn upgrade_plan_allows_removed_and_strictly_reduced_permissions() { + let current = upgrade_manifest( + "1.0.0", + json!([ + { + "capability": "storage:read", + "params": { "keys": [], "maxBytes": 4096 } + }, + { "capability": "theme:subscribe" } + ]), + 2, + ); + let candidate = upgrade_manifest( + "1.1.0", + json!([{ + "capability": "storage:read", + "params": { "keys": ["clock"], "maxBytes": 1024 } + }]), + 2, + ); + let plan = plan_upgrade(¤t, &candidate).unwrap(); + assert!(!plan.requires_confirmation); + assert!(plan.permissions.iter().any(|change| { + change.capability == CapabilityId::StorageRead + && change.kind == PermissionChangeKind::Reduced + })); + assert!(plan.permissions.iter().any(|change| { + change.capability == CapabilityId::ThemeSubscribe + && change.kind == PermissionChangeKind::Removed + })); + } + + #[test] + fn upgrade_plan_rejects_identity_version_and_migration_regressions() { + let current = upgrade_manifest("2.0.0", json!([]), 2); + let old = upgrade_manifest("1.0.0", json!([]), 2); + assert_eq!( + plan_upgrade(¤t, &old), + Err(UpgradePlanError::VersionNotNewer) + ); + let migration = upgrade_manifest("3.0.0", json!([]), 1); + assert_eq!( + plan_upgrade(¤t, &migration), + Err(UpgradePlanError::StorageMigrationRollback) + ); + let mut publisher = upgrade_manifest("3.0.0", json!([]), 2); + publisher.publisher.id = "other.publisher".to_owned(); + assert_eq!( + plan_upgrade(¤t, &publisher), + Err(UpgradePlanError::PublisherMismatch) + ); + } + + #[test] + fn rollback_plan_requires_older_compatible_non_expanding_target() { + let current = upgrade_manifest( + "2.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 10, "maxActive": 1 } + }]), + 2, + ); + let safe_target = upgrade_manifest("1.0.0", json!([]), 2); + let plan = plan_rollback(¤t, &safe_target).unwrap(); + assert_eq!( + plan.target_version, + semver::Version::parse("1.0.0").unwrap() + ); + + let expanded_target = upgrade_manifest( + "1.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 60, "maxActive": 2 } + }]), + 2, + ); + assert_eq!( + plan_rollback(¤t, &expanded_target), + Err(RollbackPlanError::PermissionExpansion) + ); + let incompatible_storage = upgrade_manifest("1.0.0", json!([]), 1); + assert_eq!( + plan_rollback(¤t, &incompatible_storage), + Err(RollbackPlanError::StorageMigrationIncompatible) + ); + } +} diff --git a/crates/floatile-core/src/install.rs b/crates/floatile-core/src/install.rs index c6c7a7a..3668c1c 100644 --- a/crates/floatile-core/src/install.rs +++ b/crates/floatile-core/src/install.rs @@ -10,6 +10,14 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstallationTrust { + #[default] + Unsigned, + Trusted, +} + /// 一个已安装插件的可验证元数据(manifest 与摘要快照)。 /// /// 不冗余存放插件内容,只存每条文件的 SHA-256 与覆盖全部规范文件集合的内容摘要, @@ -28,6 +36,9 @@ pub struct InstallMeta { pub installed_at: u64, /// 来源包文件名(仅诊断,不参与信任)。 pub source: String, + /// Installation policy used at commit time; runtime re-verifies trusted signatures. + #[serde(default)] + pub trust: InstallationTrust, /// 每条允许文件相对路径 → SHA-256 hex。 pub files: BTreeMap, /// 覆盖全部规范文件集合的内容摘要(hex)。 @@ -149,11 +160,17 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 42, source: "clock.floatile".to_owned(), + trust: InstallationTrust::Unsigned, files, digest: hex_encode(&[9; 32]), }; let json = serde_json::to_string(&meta).unwrap(); let back: InstallMeta = serde_json::from_str(&json).unwrap(); assert_eq!(back, meta); + + let mut legacy: serde_json::Value = serde_json::from_str(&json).unwrap(); + legacy.as_object_mut().unwrap().remove("trust"); + let legacy: InstallMeta = serde_json::from_value(legacy).unwrap(); + assert_eq!(legacy.trust, InstallationTrust::Unsigned); } } diff --git a/crates/floatile-core/src/lib.rs b/crates/floatile-core/src/lib.rs index cfe4bc3..92e3b73 100644 --- a/crates/floatile-core/src/lib.rs +++ b/crates/floatile-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod capability; pub mod connection; pub mod constants; +pub mod distribution; pub mod install; pub mod instance; pub mod layout; @@ -19,6 +20,12 @@ pub use connection::{ CredentialRef, MAX_CONNECTION_ACCOUNT_BYTES, MAX_CONNECTION_PROVIDER_BYTES, MAX_CREDENTIAL_REF_BYTES, }; +pub use distribution::{ + PACKAGE_DIGEST_PAYLOAD_TYPE, PermissionChange, PermissionChangeKind, RollbackPlan, + RollbackPlanError, SIGNATURE_FILE, SignatureVerificationError, TrustedPublisher, + TrustedPublisherState, UpgradePlan, UpgradePlanError, dsse_pae, plan_rollback, plan_upgrade, + publisher_key_id, signable_content_digest, verify_signature_envelope, +}; pub use instance::{ InstallationDigest, InstallationRef, InstanceConfig, InstanceDesiredState, InstanceModelError, MAX_INSTANCE_CONFIG_BYTES, MAX_INSTANCE_CONFIG_DEPTH, PluginInstance, diff --git a/crates/floatile-shell/src/instance_control.rs b/crates/floatile-shell/src/instance_control.rs index bb8d5b7..e9fcf3f 100644 --- a/crates/floatile-shell/src/instance_control.rs +++ b/crates/floatile-shell/src/instance_control.rs @@ -1213,6 +1213,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-shell/src/instance_supervisor.rs b/crates/floatile-shell/src/instance_supervisor.rs index 361fe9d..73538d2 100644 --- a/crates/floatile-shell/src/instance_supervisor.rs +++ b/crates/floatile-shell/src/instance_supervisor.rs @@ -18,7 +18,7 @@ use floatile_platform::PlatformCapabilities; use floatile_services::{AuditListener, CredentialVault, HttpsService, MemoryCredentialVault}; use slint::{Timer, TimerMode}; -use crate::plugin_manager::{RunnableInstance, load_runnable_instance}; +use crate::plugin_manager::{RunnableInstance, load_runnable_instance_with_trust}; use crate::runtime_ui::{ RuntimeUiLifecycleEvent, RuntimeUiSession, compose_instance_https, spawn_runtime_ui_with_https, }; @@ -404,7 +404,7 @@ fn prepare_actions( } }; let version = instance.installation().version().to_owned(); - match load_runnable_instance(plugin_store, instance) { + match load_runnable_instance_with_trust(plugin_store, store, instance) { Ok(Some(runnable)) => match compose_instance_https( store, runnable.instance.id(), @@ -637,6 +637,7 @@ mod tests { ui_api_version: "0.1.0".to_owned(), digest: InstallationDigest::from_bytes([id as u8; 32]).to_string(), source: "test.floatile".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, installed_at: 1, files: Default::default(), }) diff --git a/crates/floatile-shell/src/plugin_manager.rs b/crates/floatile-shell/src/plugin_manager.rs index 6bbadec..f47c8ec 100644 --- a/crates/floatile-shell/src/plugin_manager.rs +++ b/crates/floatile-shell/src/plugin_manager.rs @@ -10,7 +10,8 @@ use std::path::{Path, PathBuf}; -use floatile_core::install::InstallMeta; +use floatile_core::distribution::{SIGNATURE_FILE, verify_signature_envelope}; +use floatile_core::install::{InstallMeta, InstallationTrust}; use floatile_core::instance::InstallationRef; use floatile_core::manifest::Manifest; use floatile_core::{InstanceDesiredState, InstanceId, PluginInstance}; @@ -84,6 +85,14 @@ pub enum LoadError { InvalidManifest(#[from] floatile_core::manifest::ManifestError), #[error("实例 Config 契约校验失败: {0}")] InvalidConfig(#[from] ConfigValidationError), + #[error("受信安装缺少 detached signature.json")] + MissingSignature, + #[error("受信安装的 publisher 不在宿主 trust store 中")] + UnknownPublisher, + #[error("受信安装签名校验失败: {0}")] + Signature(#[from] floatile_core::SignatureVerificationError), + #[error("读取 publisher trust 失败: {0}")] + TrustStore(String), } impl LoadError { @@ -97,6 +106,23 @@ impl LoadError { Self::MissingEntrypoint(_) => "FLOAD_MISSING_ENTRYPOINT", Self::InvalidManifest(_) => "FLOAD_INVALID_MANIFEST", Self::InvalidConfig(error) => error.code(), + Self::MissingSignature => "FLOAD_SIGNATURE_MISSING", + Self::UnknownPublisher => "FLOAD_PUBLISHER_UNKNOWN", + Self::Signature(error) => match error { + floatile_core::SignatureVerificationError::PublisherRevoked => { + "FLOAD_PUBLISHER_REVOKED" + } + floatile_core::SignatureVerificationError::KeyRevoked => "FLOAD_KEY_REVOKED", + floatile_core::SignatureVerificationError::UnknownKey => "FLOAD_KEY_UNKNOWN", + floatile_core::SignatureVerificationError::DigestMismatch => { + "FLOAD_SIGNATURE_DIGEST" + } + floatile_core::SignatureVerificationError::InvalidSignature => { + "FLOAD_SIGNATURE_INVALID" + } + _ => "FLOAD_SIGNATURE_MALFORMED", + }, + Self::TrustStore(_) => "FLOAD_TRUST_STORE", } } } @@ -154,6 +180,30 @@ pub fn load_runnable_instance( Ok(Some(RunnableInstance { instance, plugin })) } +/// Production load path: trusted installations are re-verified against current host trust. +/// Explicit unsigned development installations remain marked and use the sandboxed dev path. +pub fn load_runnable_instance_with_trust( + plugin_store: &Path, + trust_store: &Store, + instance: PluginInstance, +) -> Result, LoadError> { + let reference = instance.installation(); + let installation = load_reference(plugin_store, reference).map_err(|error| match error { + InstallationCatalogError::MetadataMismatch => LoadError::InstallationMismatch { + id: reference.plugin().0.clone(), + version: reference.version().to_owned(), + }, + other => map_catalog_error(other), + })?; + let Some(installation) = installation else { + return Ok(None); + }; + installation.validate_config(instance.config())?; + verify_runtime_trust(&installation, trust_store)?; + let plugin = installed_plugin(installation)?; + Ok(Some(RunnableInstance { instance, plugin })) +} + /// 从持久记录恢复 desired-running 实例,并固定到精确 Installation。 /// /// 该函数执行 SQLite 与文件 I/O,必须在 Slint 事件循环启动前或后台线程调用。每次启动尝试先推进 @@ -219,7 +269,7 @@ pub fn plan_running_instances( }; let version = instance.installation().version().to_owned(); - match load_runnable_instance(plugin_store, instance) { + match load_runnable_instance_with_trust(plugin_store, store, instance) { Ok(Some(runnable)) => plan.ready.push(runnable), Ok(None) => plan.failures.push(InstanceLoadFailure { instance_id, @@ -238,6 +288,33 @@ pub fn plan_running_instances( Ok(plan) } +fn verify_runtime_trust( + installation: &InstalledInstallation, + store: &Store, +) -> Result<(), LoadError> { + if installation.meta.trust == InstallationTrust::Unsigned { + return Ok(()); + } + let publisher_id = installation.manifest.publisher.id.as_str(); + let trust = store + .trust() + .get(publisher_id) + .map_err(|error| LoadError::TrustStore(error.to_string()))? + .ok_or(LoadError::UnknownPublisher)?; + let mut files = std::collections::BTreeMap::new(); + for name in installation.meta.files.keys() { + let bytes = installation + .file(name) + .ok_or_else(|| LoadError::Read(format!("安装文件缺失: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(LoadError::MissingSignature)?; + verify_signature_envelope(envelope, &files, publisher_id, &trust.verifier_binding())?; + Ok(()) +} + /// 枚举插件存储下全部已安装插件,每个 id 取最高已安装版本并逐一做 digest 复核。 /// /// 这是宿主运行多个插件的加载策略:返回稳定的已安装插件集合,供 UI 层按 id 创建 @@ -298,6 +375,7 @@ mod tests { use std::collections::BTreeMap; use floatile_core::install::{content_digest, file_digest, hex_encode}; + use floatile_store::trust::TrustState; fn manifest_json() -> String { serde_json::json!({ @@ -363,6 +441,7 @@ mod tests { ui_api_version: "1.0.0".into(), installed_at: 0, source: "x.floatile".into(), + trust: InstallationTrust::Unsigned, files: file_digests.clone(), digest: hex_encode(&content_digest(&files)), }; @@ -391,6 +470,35 @@ mod tests { InstallationRef::from_install_meta(&meta).unwrap() } + #[test] + fn runtime_refuses_trusted_marker_without_signature() { + let plugin_store = temp_store("runtime-trust"); + write_install(&plugin_store, "dev.floatile.clock", "0.2.0", None); + let meta_path = plugin_store.join("dev.floatile.clock/0.2.0/install.json"); + let mut meta: InstallMeta = + serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap(); + meta.trust = InstallationTrust::Trusted; + std::fs::write(&meta_path, serde_json::to_vec(&meta).unwrap()).unwrap(); + let reference = InstallationRef::from_install_meta(&meta).unwrap(); + let store = floatile_store::open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [31; 32], TrustState::Active, 1) + .unwrap(); + let instance = PluginInstance::restore( + InstanceId(9), + reference, + floatile_core::InstanceConfig::empty(), + InstanceDesiredState::Stopped, + 0, + 1, + 1, + ) + .unwrap(); + let error = load_runnable_instance_with_trust(&plugin_store, &store, instance).unwrap_err(); + assert!(matches!(error, LoadError::MissingSignature)); + } + #[test] fn loads_highest_version_and_verifies_digest() { let store = temp_store("load"); diff --git a/crates/floatile-shell/src/runtime_ui.rs b/crates/floatile-shell/src/runtime_ui.rs index d163917..732e172 100644 --- a/crates/floatile-shell/src/runtime_ui.rs +++ b/crates/floatile-shell/src/runtime_ui.rs @@ -1193,6 +1193,7 @@ mod grants_tests { ui_api_version: "1.0.0".into(), installed_at: 0, source: "clock.floatile".into(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: std::collections::BTreeMap::new(), digest: "00".repeat(32), }, diff --git a/crates/floatile-shell/tests/persistent_instance_lifecycle.rs b/crates/floatile-shell/tests/persistent_instance_lifecycle.rs index 60b0f4e..4b97062 100644 --- a/crates/floatile-shell/tests/persistent_instance_lifecycle.rs +++ b/crates/floatile-shell/tests/persistent_instance_lifecycle.rs @@ -86,6 +86,7 @@ fn write_clock_install(root: &Path, wasm: Vec) -> PathBuf { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "xvfb-evidence".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-store/src/installation.rs b/crates/floatile-store/src/installation.rs index c178f28..9b40d5c 100644 --- a/crates/floatile-store/src/installation.rs +++ b/crates/floatile-store/src/installation.rs @@ -286,6 +286,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-store/src/lib.rs b/crates/floatile-store/src/lib.rs index 04ff52b..129dd75 100644 --- a/crates/floatile-store/src/lib.rs +++ b/crates/floatile-store/src/lib.rs @@ -13,6 +13,7 @@ use floatile_core::{ use rusqlite::{Connection, OptionalExtension}; pub mod installation; +pub mod trust; /// 持久化错误。 #[derive(Debug, thiserror::Error)] @@ -28,7 +29,7 @@ pub enum StoreError { } /// 当前 schema 版本(与 migration 列表一一对应)。 -const SCHEMA_VERSION: u32 = 5; +const SCHEMA_VERSION: u32 = 8; /// 打开数据库并迁移到最新版本。 /// @@ -71,6 +72,15 @@ impl Store { if current < 5 { self.migration_v5()?; } + if current < 6 { + self.migration_v6()?; + } + if current < 7 { + self.migration_v7()?; + } + if current < 8 { + self.migration_v8()?; + } Ok(()) } @@ -213,6 +223,88 @@ impl Store { .map_err(|error| StoreError::Migration(format!("v5 提交失败: {error}"))) } + fn migration_v6(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS publisher_trust ( + publisher_id TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + ); + CREATE TABLE IF NOT EXISTS publisher_keys ( + publisher_id TEXT NOT NULL, + key_id TEXT NOT NULL, + public_key BLOB NOT NULL CHECK (length(public_key) = 32), + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY (publisher_id, key_id), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + CREATE TABLE IF NOT EXISTS accepted_packages ( + publisher_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + digest BLOB NOT NULL CHECK (length(digest) = 32), + accepted_at INTEGER NOT NULL CHECK (accepted_at >= 0), + PRIMARY KEY (publisher_id, plugin_id), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + PRAGMA user_version = 6;", + ) + .map_err(|error| { + StoreError::Migration(format!( + "v6 建立 publisher trust 与 anti-rollback 表失败: {error}" + )) + })?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v6 提交失败: {error}"))) + } + + fn migration_v7(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS pending_installations ( + transaction_id TEXT PRIMARY KEY, + publisher_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + signed_digest BLOB NOT NULL CHECK (length(signed_digest) = 32), + install_digest BLOB NOT NULL CHECK (length(install_digest) = 32), + staging_name TEXT NOT NULL, + final_relative TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + PRAGMA user_version = 7;", + ) + .map_err(|error| StoreError::Migration(format!("v7 建立可恢复安装意图表失败: {error}")))?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v7 提交失败: {error}"))) + } + + fn migration_v8(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS installation_rollbacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + from_version TEXT NOT NULL, + from_digest BLOB NOT NULL CHECK (length(from_digest) = 32), + target_version TEXT NOT NULL, + target_digest BLOB NOT NULL CHECK (length(target_digest) = 32), + reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 512), + rolled_back_at INTEGER NOT NULL CHECK (rolled_back_at >= 0) + ); + CREATE INDEX IF NOT EXISTS installation_rollbacks_instance + ON installation_rollbacks(instance_id, id); + PRAGMA user_version = 8;", + ) + .map_err(|error| StoreError::Migration(format!("v8 建立显式回滚审计表失败: {error}")))?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v8 提交失败: {error}"))) + } + /// 布局存储接口。 pub fn layout(&self) -> LayoutStore<'_> { LayoutStore { conn: &self.conn } @@ -235,6 +327,11 @@ impl Store { pub fn connections(&self) -> ConnectionStore<'_> { ConnectionStore { conn: &self.conn } } + + /// 宿主持有的 publisher trust、签名 key 与 anti-rollback 状态接口。 + pub fn trust(&self) -> trust::PublisherTrustStore<'_> { + trust::PublisherTrustStore::new(&self.conn) + } } pub struct ConnectionStore<'a> { @@ -558,6 +655,70 @@ impl<'a> InstanceStore<'a> { Ok(changed == 1) } + /// Atomically rebinds a stopped instance to a verified historical installation and audits it. + pub fn rollback_installation( + &self, + instance_id: InstanceId, + current: &InstallationRef, + target: &InstallationRef, + reason: &str, + updated_at: u64, + ) -> Result { + if reason.is_empty() || reason.len() > 512 { + return Err(StoreError::Corrupt( + "rollback reason 必须为 1..=512 字节".to_owned(), + )); + } + current + .validate() + .map_err(|error| StoreError::Corrupt(error.to_string()))?; + target + .validate() + .map_err(|error| StoreError::Corrupt(error.to_string()))?; + let instance_id_sql = sqlite_i64(instance_id.0, "instance_id")?; + let updated_at_sql = sqlite_i64(updated_at, "updated_at")?; + let tx = self.conn.unchecked_transaction()?; + let changed = tx.execute( + "UPDATE plugin_instances + SET installation_version = ?1, installation_digest = ?2, updated_at = ?3 + WHERE instance_id = ?4 + AND plugin_id = ?5 + AND installation_version = ?6 + AND installation_digest = ?7 + AND desired_state = 'stopped' + AND updated_at <= ?3", + rusqlite::params![ + target.version(), + target.digest().as_bytes().as_slice(), + updated_at_sql, + instance_id_sql, + current.plugin().0, + current.version(), + current.digest().as_bytes().as_slice(), + ], + )?; + if changed == 1 { + tx.execute( + "INSERT INTO installation_rollbacks ( + instance_id, plugin_id, from_version, from_digest, + target_version, target_digest, reason, rolled_back_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + instance_id_sql, + current.plugin().0, + current.version(), + current.digest().as_bytes().as_slice(), + target.version(), + target.digest().as_bytes().as_slice(), + reason, + updated_at_sql, + ], + )?; + } + tx.commit()?; + Ok(changed == 1) + } + /// 启动或重启前推进 generation;迟到异步结果必须匹配该值才能投递。 pub fn advance_generation( &self, @@ -1292,6 +1453,15 @@ mod tests { .unwrap() } + fn historical_installation() -> InstallationRef { + InstallationRef::new( + PluginId("dev.floatile.clock".into()), + "1.0.0", + InstallationDigest::from_bytes([0x19; 32]), + ) + .unwrap() + } + fn config(label: &str) -> InstanceConfig { InstanceConfig::new(serde_json::json!({ "label": label })).unwrap() } @@ -1335,6 +1505,65 @@ mod tests { assert_eq!(instance.id(), InstanceId(2)); } + #[test] + fn rollback_rebinds_stopped_instance_and_appends_audit_atomically() { + let store = open(":memory:").unwrap(); + let instance = store + .instances() + .create( + &installation(), + &config("rollback"), + InstanceDesiredState::Stopped, + 10, + ) + .unwrap(); + assert!( + store + .instances() + .rollback_installation( + instance.id(), + instance.installation(), + &historical_installation(), + "regression in 1.2.3", + 11, + ) + .unwrap() + ); + let restored = store.instances().get(instance.id()).unwrap().unwrap(); + assert_eq!(restored.installation(), &historical_installation()); + let count: u32 = store + .conn + .query_row("SELECT COUNT(*) FROM installation_rollbacks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + + store + .instances() + .set_desired_state(instance.id(), InstanceDesiredState::Running, 12) + .unwrap(); + assert!( + !store + .instances() + .rollback_installation( + instance.id(), + &historical_installation(), + &installation(), + "must stop first", + 13, + ) + .unwrap() + ); + let count: u32 = store + .conn + .query_row("SELECT COUNT(*) FROM installation_rollbacks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + } + #[test] fn migration_v4_failure_rolls_back_allocator_and_version() { let mut store = v3_store(); @@ -1555,6 +1784,107 @@ mod tests { assert_eq!(allocator_exists, 0); } + fn v5_store() -> Store { + let conn = Connection::open_in_memory().unwrap(); + let mut store = Store { conn }; + store.migration_v1().unwrap(); + store.migration_v2().unwrap(); + store.migration_v3().unwrap(); + store.migration_v4().unwrap(); + store.migration_v5().unwrap(); + store + } + + #[test] + fn migration_v6_failure_rolls_back_all_trust_tables_and_version() { + let mut store = v5_store(); + store + .conn + .execute_batch("CREATE INDEX publisher_trust ON layout(instance_id);") + .unwrap(); + + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 5); + for table in ["publisher_trust", "accepted_packages"] { + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0, "{table} leaked from failed migration"); + } + } + + fn v6_store() -> Store { + let conn = Connection::open_in_memory().unwrap(); + let mut store = Store { conn }; + store.migration_v1().unwrap(); + store.migration_v2().unwrap(); + store.migration_v3().unwrap(); + store.migration_v4().unwrap(); + store.migration_v5().unwrap(); + store.migration_v6().unwrap(); + store + } + + #[test] + fn migration_v7_failure_rolls_back_pending_table_and_version() { + let mut store = v6_store(); + store + .conn + .execute_batch("CREATE INDEX pending_installations ON layout(instance_id);") + .unwrap(); + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 6); + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'pending_installations'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0); + } + + #[test] + fn migration_v8_failure_rolls_back_audit_table_and_version() { + let mut store = v6_store(); + store.migration_v7().unwrap(); + store + .conn + .execute_batch("CREATE INDEX installation_rollbacks ON layout(instance_id);") + .unwrap(); + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 7); + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'installation_rollbacks'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0); + } + #[test] fn connection_grants_are_instance_scoped_and_shared_connection_survives_deletion() { let store = open(":memory:").unwrap(); diff --git a/crates/floatile-store/src/trust.rs b/crates/floatile-store/src/trust.rs new file mode 100644 index 0000000..e20efea --- /dev/null +++ b/crates/floatile-store/src/trust.rs @@ -0,0 +1,902 @@ +//! Host-owned publisher trust persistence for PP-M8. +//! +//! Package-controlled data never creates a trust anchor. Callers must explicitly provision or revoke +//! publisher keys through this store, then pass only active keys to the core signature verifier. + +use floatile_core::distribution::{TrustedPublisher, TrustedPublisherState, publisher_key_id}; +use rusqlite::{Connection, OptionalExtension}; +use semver::Version; + +use crate::StoreError; + +const MAX_PUBLISHER_ID_BYTES: usize = 256; +const MAX_PLUGIN_ID_BYTES: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustState { + Active, + Revoked, +} + +impl TrustState { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Revoked => "revoked", + } + } + + fn parse(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "revoked" => Ok(Self::Revoked), + other => Err(StoreError::Corrupt(format!( + "publisher trust state 非法: {other}" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublisherKeyRecord { + pub key_id: String, + pub public_key: [u8; 32], + pub state: TrustState, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublisherTrustRecord { + pub publisher_id: String, + pub state: TrustState, + pub updated_at: u64, + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedPackage { + pub publisher_id: String, + pub plugin_id: String, + pub version: Version, + pub digest: [u8; 32], + pub accepted_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingInstallation { + pub transaction_id: String, + pub publisher_id: String, + pub plugin_id: String, + pub version: Version, + pub signed_digest: [u8; 32], + pub install_digest: [u8; 32], + pub staging_name: String, + pub final_relative: String, + pub created_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceptanceOutcome { + FirstAccepted, + NewerAccepted, + AlreadyAccepted, +} + +#[derive(Debug, thiserror::Error)] +pub enum TrustPolicyError { + #[error(transparent)] + Store(#[from] StoreError), + #[error("publisher trust 不存在")] + UnknownPublisher, + #[error("publisher trust 已撤销")] + RevokedPublisher, + #[error("拒绝低于最高已接受版本的包: {candidate} < {highest}")] + Rollback { + candidate: Version, + highest: Version, + }, + #[error("同版本包的内容摘要与最高已接受记录不同")] + SameVersionDifferentDigest, +} + +impl From for TrustPolicyError { + fn from(error: rusqlite::Error) -> Self { + Self::Store(StoreError::Open(error)) + } +} + +impl PublisherTrustRecord { + /// Produces a verifier binding containing only active host-trusted keys. + pub fn verifier_binding(&self) -> TrustedPublisher { + TrustedPublisher { + publisher_id: self.publisher_id.clone(), + state: match self.state { + TrustState::Active => TrustedPublisherState::Active, + TrustState::Revoked => TrustedPublisherState::Revoked, + }, + keys: self + .keys + .iter() + .filter(|key| key.state == TrustState::Active) + .map(|key| key.public_key) + .collect(), + revoked_key_ids: self + .keys + .iter() + .filter(|key| key.state == TrustState::Revoked) + .map(|key| key.key_id.clone()) + .collect(), + } + } +} + +pub struct PublisherTrustStore<'a> { + conn: &'a Connection, +} + +impl<'a> PublisherTrustStore<'a> { + pub(crate) fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Provisions or replaces a publisher key and makes publisher trust explicit. + pub fn upsert_key( + &self, + publisher_id: &str, + public_key: [u8; 32], + state: TrustState, + updated_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + let updated_at = sqlite_time(updated_at)?; + let key_id = publisher_key_id(&public_key); + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO publisher_trust (publisher_id, state, updated_at) + VALUES (?1, 'active', ?2) + ON CONFLICT(publisher_id) DO UPDATE SET updated_at = excluded.updated_at", + rusqlite::params![publisher_id, updated_at], + )?; + tx.execute( + "INSERT INTO publisher_keys (publisher_id, key_id, public_key, state, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(publisher_id, key_id) DO UPDATE SET + public_key = excluded.public_key, + state = excluded.state, + updated_at = excluded.updated_at", + rusqlite::params![ + publisher_id, + key_id, + public_key.as_slice(), + state.as_str(), + updated_at + ], + )?; + tx.commit()?; + Ok(key_id) + } + + /// Revokes or reactivates an existing publisher without modifying its keys. + pub fn set_publisher_state( + &self, + publisher_id: &str, + state: TrustState, + updated_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + let changed = self.conn.execute( + "UPDATE publisher_trust SET state = ?2, updated_at = ?3 WHERE publisher_id = ?1", + rusqlite::params![publisher_id, state.as_str(), sqlite_time(updated_at)?], + )?; + Ok(changed > 0) + } + + pub fn get(&self, publisher_id: &str) -> Result, StoreError> { + validate_publisher_id(publisher_id)?; + let publisher = self + .conn + .query_row( + "SELECT state, updated_at FROM publisher_trust WHERE publisher_id = ?1", + [publisher_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + let Some((state, updated_at)) = publisher else { + return Ok(None); + }; + + let mut statement = self.conn.prepare( + "SELECT key_id, public_key, state, updated_at + FROM publisher_keys WHERE publisher_id = ?1 ORDER BY key_id", + )?; + let rows = statement.query_map([publisher_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + })?; + let mut keys = Vec::new(); + for row in rows { + let (key_id, public_key, key_state, key_updated_at) = row?; + let public_key: [u8; 32] = public_key.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "publisher public key 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?; + if publisher_key_id(&public_key) != key_id { + return Err(StoreError::Corrupt( + "publisher key_id 与 public_key 不匹配".to_owned(), + )); + } + keys.push(PublisherKeyRecord { + key_id, + public_key, + state: TrustState::parse(&key_state)?, + updated_at: read_time(key_updated_at, "publisher key updated_at")?, + }); + } + + Ok(Some(PublisherTrustRecord { + publisher_id: publisher_id.to_owned(), + state: TrustState::parse(&state)?, + updated_at: read_time(updated_at, "publisher trust updated_at")?, + keys, + })) + } + + /// Atomically advances the highest accepted version/digest for a trusted publisher/plugin. + /// + /// Explicit rollback does not call this method: it references a verified historical installation + /// while retaining this high-water mark. + pub fn accept_package( + &self, + publisher_id: &str, + plugin_id: &str, + version: &Version, + digest: [u8; 32], + accepted_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + validate_plugin_id(plugin_id)?; + let accepted_at = sqlite_time(accepted_at)?; + let tx = self.conn.unchecked_transaction()?; + let publisher_state = tx + .query_row( + "SELECT state FROM publisher_trust WHERE publisher_id = ?1", + [publisher_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + match publisher_state.as_deref() { + None => return Err(TrustPolicyError::UnknownPublisher), + Some("revoked") => return Err(TrustPolicyError::RevokedPublisher), + Some("active") => {} + Some(other) => { + return Err( + StoreError::Corrupt(format!("publisher trust state 非法: {other}")).into(), + ); + } + } + + let existing = tx + .query_row( + "SELECT version, digest, accepted_at FROM accepted_packages + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![publisher_id, plugin_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((highest_text, highest_digest, _)) = existing else { + tx.execute( + "INSERT INTO accepted_packages + (publisher_id, plugin_id, version, digest, accepted_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + publisher_id, + plugin_id, + version.to_string(), + digest.as_slice(), + accepted_at + ], + )?; + tx.commit()?; + return Ok(AcceptanceOutcome::FirstAccepted); + }; + let highest = Version::parse(&highest_text).map_err(|error| { + StoreError::Corrupt(format!("accepted package version 非法: {error}")) + })?; + let highest_digest: [u8; 32] = highest_digest.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "accepted package digest 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?; + + if version < &highest { + return Err(TrustPolicyError::Rollback { + candidate: version.clone(), + highest, + }); + } + if version == &highest { + if digest == highest_digest { + return Ok(AcceptanceOutcome::AlreadyAccepted); + } + return Err(TrustPolicyError::SameVersionDifferentDigest); + } + + tx.execute( + "UPDATE accepted_packages SET version = ?3, digest = ?4, accepted_at = ?5 + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![ + publisher_id, + plugin_id, + version.to_string(), + digest.as_slice(), + accepted_at + ], + )?; + tx.commit()?; + Ok(AcceptanceOutcome::NewerAccepted) + } + + pub fn accepted_package( + &self, + publisher_id: &str, + plugin_id: &str, + ) -> Result, StoreError> { + validate_publisher_id(publisher_id)?; + validate_plugin_id(plugin_id)?; + let row = self + .conn + .query_row( + "SELECT version, digest, accepted_at FROM accepted_packages + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![publisher_id, plugin_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((version, digest, accepted_at)) = row else { + return Ok(None); + }; + Ok(Some(AcceptedPackage { + publisher_id: publisher_id.to_owned(), + plugin_id: plugin_id.to_owned(), + version: Version::parse(&version).map_err(|error| { + StoreError::Corrupt(format!("accepted package version 非法: {error}")) + })?, + digest: digest.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "accepted package digest 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?, + accepted_at: read_time(accepted_at, "accepted_at")?, + })) + } + + /// Records a recoverable install intent without advancing the anti-rollback high-water mark. + pub fn prepare_install(&self, pending: &PendingInstallation) -> Result<(), TrustPolicyError> { + validate_pending(pending)?; + self.check_candidate( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + )?; + self.conn.execute( + "INSERT INTO pending_installations ( + transaction_id, publisher_id, plugin_id, version, signed_digest, + install_digest, staging_name, final_relative, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + pending.transaction_id, + pending.publisher_id, + pending.plugin_id, + pending.version.to_string(), + pending.signed_digest.as_slice(), + pending.install_digest.as_slice(), + pending.staging_name, + pending.final_relative, + sqlite_time(pending.created_at)? + ], + )?; + Ok(()) + } + + /// Rechecks policy, advances the high-water mark, then removes the recoverable intent. + /// + /// If the process stops after acceptance but before intent removal, recovery observes an exact + /// already-accepted version/digest and can safely remove the stale intent. + pub fn finalize_install( + &self, + transaction_id: &str, + accepted_at: u64, + ) -> Result { + let pending = self + .pending_install(transaction_id)? + .ok_or_else(|| StoreError::Corrupt("安装意图不存在".to_owned()))?; + let outcome = self.accept_package( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + accepted_at, + )?; + self.conn.execute( + "DELETE FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + )?; + Ok(outcome) + } + + pub fn abort_install(&self, transaction_id: &str) -> Result { + validate_transaction_id(transaction_id)?; + Ok(self.conn.execute( + "DELETE FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + )? > 0) + } + + pub fn pending_install( + &self, + transaction_id: &str, + ) -> Result, StoreError> { + validate_transaction_id(transaction_id)?; + let row = self + .conn + .query_row( + "SELECT publisher_id, plugin_id, version, signed_digest, install_digest, + staging_name, final_relative, created_at + FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Vec>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, + )) + }, + ) + .optional()?; + row.map(|row| row_to_pending(transaction_id, row)) + .transpose() + } + + pub fn pending_installs(&self) -> Result, StoreError> { + let mut statement = self.conn.prepare( + "SELECT transaction_id, publisher_id, plugin_id, version, signed_digest, + install_digest, staging_name, final_relative, created_at + FROM pending_installations ORDER BY transaction_id", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Vec>(4)?, + row.get::<_, Vec>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + })?; + let mut result = Vec::new(); + for row in rows { + let (id, publisher, plugin, version, signed, install, staging, final_path, created) = + row?; + result.push(row_to_pending( + &id, + ( + publisher, plugin, version, signed, install, staging, final_path, created, + ), + )?); + } + Ok(result) + } + + fn check_candidate( + &self, + publisher_id: &str, + plugin_id: &str, + version: &Version, + digest: [u8; 32], + ) -> Result<(), TrustPolicyError> { + let publisher = self.get(publisher_id)?; + match publisher.map(|record| record.state) { + None => return Err(TrustPolicyError::UnknownPublisher), + Some(TrustState::Revoked) => return Err(TrustPolicyError::RevokedPublisher), + Some(TrustState::Active) => {} + } + if let Some(highest) = self.accepted_package(publisher_id, plugin_id)? { + if version < &highest.version { + return Err(TrustPolicyError::Rollback { + candidate: version.clone(), + highest: highest.version, + }); + } + if version == &highest.version && digest != highest.digest { + return Err(TrustPolicyError::SameVersionDifferentDigest); + } + } + Ok(()) + } +} + +type PendingRow = ( + String, + String, + String, + Vec, + Vec, + String, + String, + i64, +); + +fn row_to_pending( + transaction_id: &str, + row: PendingRow, +) -> Result { + let (publisher_id, plugin_id, version, signed, install, staging_name, final_relative, created) = + row; + let pending = PendingInstallation { + transaction_id: transaction_id.to_owned(), + publisher_id, + plugin_id, + version: Version::parse(&version) + .map_err(|error| StoreError::Corrupt(format!("pending version 非法: {error}")))?, + signed_digest: digest_array(signed, "pending signed_digest")?, + install_digest: digest_array(install, "pending install_digest")?, + staging_name, + final_relative, + created_at: read_time(created, "pending created_at")?, + }; + validate_pending(&pending)?; + Ok(pending) +} + +fn digest_array(bytes: Vec, field: &str) -> Result<[u8; 32], StoreError> { + bytes.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!("{field} 必须为 32 字节,实际为 {}", bytes.len())) + }) +} + +fn validate_pending(pending: &PendingInstallation) -> Result<(), StoreError> { + validate_transaction_id(&pending.transaction_id)?; + validate_publisher_id(&pending.publisher_id)?; + validate_plugin_id(&pending.plugin_id)?; + if pending.staging_name != format!(".staging-{}", pending.transaction_id) + || pending.final_relative != format!("{}/{}", pending.plugin_id, pending.version) + { + return Err(StoreError::Corrupt( + "安装意图包含非规范化相对路径".to_owned(), + )); + } + Ok(()) +} + +fn validate_transaction_id(transaction_id: &str) -> Result<(), StoreError> { + if transaction_id.is_empty() + || transaction_id.len() > 128 + || !transaction_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(StoreError::Corrupt("安装 transaction id 非法".to_owned())); + } + Ok(()) +} + +fn validate_publisher_id(publisher_id: &str) -> Result<(), StoreError> { + if publisher_id.is_empty() || publisher_id.len() > MAX_PUBLISHER_ID_BYTES { + return Err(StoreError::Corrupt( + "publisher id 为空或超过存储上限".to_owned(), + )); + } + Ok(()) +} + +fn validate_plugin_id(plugin_id: &str) -> Result<(), StoreError> { + if plugin_id.is_empty() || plugin_id.len() > MAX_PLUGIN_ID_BYTES { + return Err(StoreError::Corrupt( + "plugin id 为空或超过存储上限".to_owned(), + )); + } + Ok(()) +} + +fn sqlite_time(value: u64) -> Result { + i64::try_from(value).map_err(|_| StoreError::Corrupt("publisher trust 时间超出范围".to_owned())) +} + +fn read_time(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("{field} 不能为负数"))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::open; + + #[test] + fn publisher_keys_round_trip_and_revocation_is_enforced_in_binding() { + let store = open(":memory:").unwrap(); + let first = [7; 32]; + let second = [8; 32]; + let first_id = store + .trust() + .upsert_key("dev.floatile", first, TrustState::Active, 10) + .unwrap(); + store + .trust() + .upsert_key("dev.floatile", second, TrustState::Revoked, 11) + .unwrap(); + + let record = store.trust().get("dev.floatile").unwrap().unwrap(); + assert_eq!(record.keys.len(), 2); + assert!(record.keys.iter().any(|key| key.key_id == first_id)); + assert_eq!(record.verifier_binding().keys, vec![first]); + + assert!( + store + .trust() + .set_publisher_state("dev.floatile", TrustState::Revoked, 12) + .unwrap() + ); + assert_eq!( + store + .trust() + .get("dev.floatile") + .unwrap() + .unwrap() + .verifier_binding() + .state, + TrustedPublisherState::Revoked + ); + } + + #[test] + fn unknown_publisher_is_not_implicitly_created_by_reads_or_state_changes() { + let store = open(":memory:").unwrap(); + assert!(store.trust().get("unknown.publisher").unwrap().is_none()); + assert!( + !store + .trust() + .set_publisher_state("unknown.publisher", TrustState::Active, 1) + .unwrap() + ); + assert!(store.trust().get("unknown.publisher").unwrap().is_none()); + } + + #[test] + fn corrupt_key_id_is_rejected_on_load() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [9; 32], TrustState::Active, 1) + .unwrap(); + store + .conn + .execute( + "UPDATE publisher_keys SET key_id = ?1 WHERE publisher_id = ?2", + rusqlite::params!["00".repeat(32), "dev.floatile"], + ) + .unwrap(); + assert!(matches!( + store.trust().get("dev.floatile"), + Err(StoreError::Corrupt(_)) + )); + } + + #[test] + fn anti_rollback_high_water_mark_rejects_downgrade_and_replacement() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [3; 32], TrustState::Active, 1) + .unwrap(); + let v1 = Version::parse("1.0.0").unwrap(); + let v2 = Version::parse("2.0.0").unwrap(); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v1, [1; 32], 2) + .unwrap(), + AcceptanceOutcome::FirstAccepted + ); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [2; 32], 3) + .unwrap(), + AcceptanceOutcome::NewerAccepted + ); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [2; 32], 4) + .unwrap(), + AcceptanceOutcome::AlreadyAccepted + ); + assert!(matches!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v1, [1; 32], 5), + Err(TrustPolicyError::Rollback { .. }) + )); + assert!(matches!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [9; 32], 6), + Err(TrustPolicyError::SameVersionDifferentDigest) + )); + + let accepted = store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, v2); + assert_eq!(accepted.digest, [2; 32]); + assert_eq!(accepted.accepted_at, 3); + } + + #[test] + fn anti_rollback_requires_active_explicit_publisher_trust() { + let store = open(":memory:").unwrap(); + let version = Version::parse("1.0.0").unwrap(); + assert!(matches!( + store.trust().accept_package( + "unknown.publisher", + "unknown.publisher.plugin", + &version, + [1; 32], + 1 + ), + Err(TrustPolicyError::UnknownPublisher) + )); + store + .trust() + .upsert_key("dev.floatile", [4; 32], TrustState::Active, 1) + .unwrap(); + store + .trust() + .set_publisher_state("dev.floatile", TrustState::Revoked, 2) + .unwrap(); + assert!(matches!( + store.trust().accept_package( + "dev.floatile", + "dev.floatile.clock", + &version, + [1; 32], + 3 + ), + Err(TrustPolicyError::RevokedPublisher) + )); + assert!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .is_none() + ); + } + + fn pending(version: &str) -> PendingInstallation { + PendingInstallation { + transaction_id: "123-456".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse(version).unwrap(), + signed_digest: [5; 32], + install_digest: [6; 32], + staging_name: ".staging-123-456".to_owned(), + final_relative: format!("dev.floatile.clock/{version}"), + created_at: 10, + } + } + + #[test] + fn prepared_install_does_not_advance_watermark_until_finalize() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + assert!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .is_none() + ); + assert_eq!(store.trust().pending_installs().unwrap(), vec![pending]); + + assert_eq!( + store.trust().finalize_install("123-456", 11).unwrap(), + AcceptanceOutcome::FirstAccepted + ); + assert!(store.trust().pending_installs().unwrap().is_empty()); + assert_eq!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap() + .digest, + [5; 32] + ); + } + + #[test] + fn recovery_clears_intent_if_acceptance_committed_before_cleanup() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + store + .trust() + .accept_package( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + 11, + ) + .unwrap(); + + assert_eq!( + store.trust().finalize_install("123-456", 12).unwrap(), + AcceptanceOutcome::AlreadyAccepted + ); + assert!(store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn invalid_install_paths_and_abort_are_bounded() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let mut invalid = pending("1.0.0"); + invalid.final_relative = "../escape".to_owned(); + assert!(matches!( + store.trust().prepare_install(&invalid), + Err(TrustPolicyError::Store(StoreError::Corrupt(_))) + )); + + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + assert!(store.trust().abort_install("123-456").unwrap()); + assert!(store.trust().pending_installs().unwrap().is_empty()); + } +} diff --git a/docs/README.md b/docs/README.md index 263e37e..a243972 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ | 技术选型与版本 | `architecture/technology-stack.md` | 引入/替换关键工具或版本策略变化 | | 不可逆决策 | `architecture/decisions/` | 兼容性、安全、持久化、许可或平台承诺变化 | | 风险与假设 | `architecture/risks.md` | 发现新风险或获得验证结论 | +| 分发与信任 | `architecture/decisions/0005-package-signing-and-trust.md`、`plugin-sdk/manifest-v1.md` | 签名格式、publisher trust、撤销、更新或回滚策略变化 | | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | | 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | diff --git a/docs/architecture/decisions/0005-package-signing-and-trust.md b/docs/architecture/decisions/0005-package-signing-and-trust.md new file mode 100644 index 0000000..44d1154 --- /dev/null +++ b/docs/architecture/decisions/0005-package-signing-and-trust.md @@ -0,0 +1,114 @@ +# ADR-0005:插件包签名、发布者信任与更新防降级 + +> 状态:Accepted +> 日期:2026-08-28 +> 决策者:Floatile 项目 + +## 背景与需求 + +PP-M8 要求安装与升级能够解释来源、发布者、权限变化和失败原因,并覆盖篡改、降级、撤销与回滚。 +现有 `.floatile` 校验和 `content_digest` 能发现安装前后内容变化,但不能证明发布者身份,也不能阻止 +攻击者用签名正确的旧版本替换新版本。签名格式、摘要文件集合和信任锚一旦发布就形成长期兼容承诺, +必须先于实现固定边界。 + +本 ADR 不解除 NFR-LEGAL-01:许可 ADR 通过前仍不得对外分发宿主、SDK 或插件包。 + +## 候选方案 + +### A. manifest 内嵌裸 Ed25519 签名 + +拒绝。签名字段会造成自引用摘要问题;裸签名没有 payload type 域分离,容易被跨协议误用;对密钥轮换 +和多签名也缺少清晰扩展边界。 + +### B. detached DSSE envelope + Ed25519 + +采用。DSSE 的 PAE 将 payload type 与 payload bytes 一起认证,不依赖 JSON canonicalization;Ed25519 +使用固定 32 字节公钥和 64 字节签名。`signature.json` 只承载 envelope,不参与被签名内容摘要。 + +参考规范: + +- RFC 8032: +- DSSE protocol: +- TUF rollback guidance: + +### C. 直接采用完整 TUF/Sigstore 在线基础设施 + +当前不采用。它们适合仓库元数据、透明日志和在线发布,但会把网络、PKI、账户和运营体系同时带入首个 +本地验证切片。格式保留未来把 trusted key 来源升级为 TUF/Sigstore 的空间,不在包校验器中硬编码服务。 + +## 决策 + +### 1. 签名文件与摘要集合 + +- 包内签名文件固定为根路径 `signature.json`;manifest v1 不增加 `signature` 字段。 +- `content_digest` 的签名输入是通过全部包安全校验的规范普通文件集合,唯一排除 + `signature.json`。任何其他文件的增删、重命名或字节变化都改变摘要。 +- 安装完整性摘要继续覆盖落盘的全部普通文件,包括 `signature.json`;签名摘要与安装摘要是两个不同 + 目的的值,不得混用。 +- 签名必须在解析 manifest、UI IR 或 WASM 之前可独立验证,但 zip 路径、大小、重复和压缩预算必须先 + 通过,避免签名解析绕过不受信输入预算。 + +### 2. Envelope v1 + +`signature.json` 使用有界 DSSE JSON envelope: + +```json +{ + "payloadType": "application/vnd.floatile.package-digest.v1", + "payload": "", + "signatures": [ + { "keyid": "", "sig": "" } + ] +} +``` + +- verifier 必须严格要求上述 payload type、32 字节 payload、至少一个且有上限的 signature;未知字段 + 默认拒绝,格式升级使用新的 payload type。 +- Ed25519 签名对象是 DSSE PAE bytes,不是 payload、base64 文本或 JSON envelope。 +- `keyid` 只是候选密钥查找提示,不是身份或授权事实;它必须等于公钥原始 32 字节的 SHA-256 hex, + 但只有宿主信任存储中的公钥可以建立 trust。 +- v1 只接受 Ed25519;不从不受信 envelope 读取或协商 `alg`。 + +### 3. 发布者与信任 + +- manifest `publisher.id` 是被摘要覆盖的声明,不是自证身份。 +- 宿主信任存储维护 `publisher id → trusted public keys + state`;包内公钥、证书或 keyid 不得自动成为 + trust anchor。 +- 至少一个有效签名必须来自该 publisher 当前 trusted key,且 manifest publisher 与 trust binding + 精确匹配,才能得到 `trusted`。 +- trust outcome 使用 `unsigned | untrusted | trusted | revoked` 显式表达。开发策略可以允许 + `unsigned/untrusted` 安装,但必须标记来源并禁止静默提升为生产信任;分发策略默认拒绝。 +- 密钥轮换由宿主信任存储更新,或由旧、新 trusted key 的重叠签名证明;仅由新 key 自签不能轮换。 + +### 4. 权限、升级与回滚 + +- 签名只证明完整性和受信发布者,不授予 capability。Permission Broker 和用户 grant 继续独立、 + deny-by-default。 +- 更新计划必须比较精确插件 id、publisher、semver、内容摘要、engine/UI 兼容性和权限集合。 +- 新增 capability、扩大 scope/配额或改变 Connection 绑定必须重新确认;降权可以自动接受但要审计。 +- 宿主按 `publisher + plugin id` 记录最高已接受版本及摘要。较低版本、同版本不同摘要、已撤销 key 或 + publisher 改变默认拒绝。 +- 显式回滚必须引用已验证的历史 Installation、记录原因并保持数据 migration 兼容;回滚不是删除 + anti-rollback 状态,也不能让旧权限重新扩大。 + +### 5. 资源与失败语义 + +- envelope、字符串、签名数量和 base64 解码结果都有硬上限;解析失败不得回显签名字节、公钥或宿主 + trust-store 路径。 +- 稳定错误至少区分:missing、malformed、unsupported payload、digest mismatch、unknown key、 + invalid signature、publisher mismatch、revoked、rollback、permission confirmation required。 +- 任一失败必须在安装目录原子落盘前发生,并证明零残留;校验失败不能影响已安装版本。 + +## 后果 + +收益:签名无 JSON canonicalization、自引用或算法降级;publisher identity、权限和升级策略不会被一个 +“签名有效”布尔值混淆;未来可以替换 trust-key 配送方式而不改变包内容签名。 + +代价:需要新的纯领域 trust/signature 类型、Ed25519 依赖、host-owned trust store、anti-rollback +持久化和升级状态机。完整 TUF/Sigstore、在线撤销和透明日志留给后续 ADR/切片。 + +## 验证要求 + +首批实现必须覆盖已知 Ed25519 向量、DSSE PAE 域分离、签名/摘要/路径篡改、未知 key、publisher +错配、撤销、签名数量/大小预算、同版本不同摘要和降级拒绝。CLI 安装集成必须额外证明失败零残留与 +既有 unsigned dev policy 的显式结果。 diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 52882c3..40389ec 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -13,6 +13,7 @@ Wayland 产品承诺、许可与分发方式。普通实现细节不需要 ADR | [ADR-0002](0002-runtime-plugin-ui-rendering.md) | Implemented | 运行时第三方插件 UI 用 `slint-interpreter` 编译 renderer 生成的源码 | | [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | 当前 StarlingMonkey 资源门失败、QuickJS 契约门失败;暂不发布 TypeScript SDK | | [ADR-0004](0004-brokered-async-operations.md) | Implemented | 长任务采用 Broker 化 Operation ID、元数据完成信号与 capability-specific typed result;v1.1 已接入首个 `storage:read` typed contract | +| [ADR-0005](0005-package-signing-and-trust.md) | Accepted | detached DSSE/Ed25519 签名规范;宿主持有 publisher trust;权限授权与 anti-rollback 独立强制 | 文件名使用 `NNNN-short-title.md`,编号递增。Accepted ADR 不改写结论;需要改变时新增 ADR,并在 旧文件标记 `Superseded by ADR-NNNN`。 diff --git a/docs/architecture/risks.md b/docs/architecture/risks.md index 7f13432..3a8cbd7 100644 --- a/docs/architecture/risks.md +++ b/docs/architecture/risks.md @@ -108,6 +108,14 @@ 组件按 minor,破坏语义按 major;Custom UI 必须新 ADR。 - P0 验证:至少 clock、system monitor、countdown 三种内部 fixture 证明模型,不用预留未验证控件。 +### R17. 包签名与发布者信任混淆 — 高 +- 攻击者可能利用包内自带 key、`keyid`、合法旧版本或同版本替换包绕过来源判断;宿主也可能把 + “签名有效”错误等同于 capability 授权,造成静默提权或降级攻击。 +- 缓解:按 ADR-0005 使用 detached DSSE/Ed25519;信任锚只来自宿主持有的 publisher trust store; + Permission Broker 授权独立;持久化最高已接受版本/摘要并显式处理撤销、权限 diff 和回滚。 +- P0 验证:覆盖摘要、签名、路径和 publisher 篡改,未知/撤销 key、同版本不同摘要、降级、显式回滚、 + 权限扩大确认及失败零残留;在线撤销和透明日志在引入前另行 ADR。 + ## 2. 依赖选型 | 领域 | 选型 | 理由 | 风险备注 | diff --git a/docs/plugin-sdk/manifest-v1.md b/docs/plugin-sdk/manifest-v1.md index a977a14..6789ac3 100644 --- a/docs/plugin-sdk/manifest-v1.md +++ b/docs/plugin-sdk/manifest-v1.md @@ -92,7 +92,7 @@ manifest 是安装与运行时的显式事实,不是开发者主要编辑界 | `config.schema` | 否 | 包内规范路径;缺省表示无用户配置;只允许当前 schema 文档内 fragment 引用 | | `storage.migrationVersion` | 否 | 非负整数;只描述插件私有 KV 迁移 | | `build` | 否 | 诊断元数据,不参与信任/授权 | -| `signature` | P0 否 | 进入分发前另行 ADR 与 schema | +| `signature` | 不存在 | ADR-0005 使用 detached 根路径 `signature.json`;manifest 不内嵌自引用签名 | manifest 不重复存放 State/Event schema;它们属于 UI IR 并与组件树同 digest。宿主加载 UI IR 后 必须验证内嵌 schema 与 State bindings/event declarations 一致。 @@ -154,7 +154,7 @@ ui/widget.ftui logic/plugin.wasm assets/ config.schema.json # 可选 -signature.json # P0 不使用,未来分发版本可选/必需 +signature.json # ADR-0005 detached DSSE envelope;开发策略可缺省,分发策略必需 ``` P0 不允许 `.slint`、原生库、脚本 entrypoint、符号链接或额外可执行文件。即使文件未被 manifest diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index ff0cc07..d37fc98 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -210,9 +210,12 @@ Detected capabilities: | `floatile preview` | 固定 size/DPI/theme/locale 渲染与截图 | | `floatile build` | 可复现地产生 `.floatile`,默认不签名 | | `floatile inspect` | 显示 manifest、版本轴、权限、预算、entry digest | +| `floatile install [--require-trusted] [--accept-permissions]` | 开发模式允许显式 unsigned 安装;分发模式强制 publisher trust、签名、撤销、anti-rollback 与升级权限确认 | +| `floatile trust add-key/revoke-key/revoke-publisher/show` | 管理宿主持有的 Ed25519 public key 信任;不得接收或显示 private key | | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | | `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | | `floatile instance create/list/get/configure/start/stop/delete` | 按精确安装版本管理持久实例与 desired state | +| `floatile instance rollback` | 将 stopped 实例显式重绑到当前信任仍有效的历史 Installation,记录原因且不降低 anti-rollback 水位 | 所有命令必须支持: @@ -224,7 +227,7 @@ Detected capabilities: 退出码与诊断 code 稳定;日志文本不是自动化接口。 -当前已落地:`new/validate/check/dev/test/preview/build/install/run/inspect/instance`;`test` 用 +当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance`;`test` 用 `floatile-runtime::harness` 对已构建包跑无头生命周期冒烟。`instance` 子命令提供单一 JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、`--store`、 `--config`/`--config-file`与 `--no-interactive`。`inspect` 在输出前复用完整包安全校验,提供 manifest、 @@ -233,6 +236,20 @@ JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、` `check` 在自动清理的临时目录中复用正式 build + package validation + inspect 链,返回 `metadata/wasm/ui/manifest/package` 五阶段、warning 数组和 inspection 结果;支持相同的三个自动化选项, JSON 失败使用稳定 `FBUILD_*`/`FPAK_*`/`FCHECK_*` code 和不含宿主路径/cargo stderr 的有界描述。 +`install --require-trusted` 从 `--db`/`FLOATTILE_DB_PATH`/平台数据目录读取宿主 trust,强制 detached +DSSE/Ed25519 验证、publisher/key 撤销和 anti-rollback;默认 `install` 保留 PP-M4 本地开发所需的 +`unsigned` 结果并明确输出 trust 状态。受信安装使用 SQLite pending intent 协调 staging/rename/最高版本 +水位,下一次受信安装会先复核并恢复中断事务。`trust` 命令只接收 32 字节 public key hex,输出 key id +和状态,不存储或回显 private key。受信升级会比较当前最高安装的 manifest:新增 capability 或扩大 +scope/配额返回 `FINST_PERMISSION_CONFIRMATION` 且零落盘;调用方必须显式传 +`--accept-permissions` 才能继续。纯移除/收窄无需该参数,成功输出包含逐 capability 的 upgrade diff。 +`instance rollback --version --reason ` 仅接受 stopped 实例;目标安装先复核 +install digest 与当前 publisher/key trust,且 storage migration 必须相等、旧权限不得重新扩大。实例 +重绑定与原因审计原子提交,最高已接受版本/摘要保持不变。 +`install.json.trust` 固定为 `unsigned | trusted`;旧元数据缺字段按 `unsigned` 读取,不允许静默提升。 +Shell 的持久实例启动与动态 retry 路径会对 `trusted` 安装使用当前 trust store 重新验签,并以稳定 +`FLOAD_*` code 隔离 publisher/key 撤销、未知 key、摘要变化或签名损坏;`unsigned` 仅保留明确标记的 +本地开发降级路径,仍经过 Installation digest、manifest、Config 与 runtime sandbox 校验。 `check` 按 Component Model 实际保留的 Floatile interface/function imports 与 Capability Registry 比对: 使用声明能力但 manifest 未声明时以 `FCHECK_CAPABILITY_MISSING` 失败;已声明但未导入时产生 `FCHECK_CAPABILITY_UNUSED` warning,`--deny-warnings` 可将其提升为失败。固有能力无需写入 manifest; diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index fadabe1..174d429 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -174,7 +174,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | | PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | -| PP-M8 | 分发与信任 | 规划中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | +| PP-M8 | 分发与信任 | 已完成(自动化契约验证;公开分发受许可门阻断) | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -223,6 +223,14 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; +- PP-M8 已由 ADR-0005 固定 detached DSSE/Ed25519 与宿主持有的 publisher/key trust。CLI 受信安装 + 强制当前信任、撤销、anti-rollback 和权限扩大显式确认;SQLite journal 可恢复 rename 与水位之间的 + 崩溃窗口。显式回滚只重绑 stopped 实例到重新验签、migration 兼容且不恢复旧权限的历史 + Installation,原因审计与重绑定原子提交且最高水位不降低。Shell 在持久启动和 retry 前按 + `install.json.trust` 对 trusted 安装重新应用当前 trust,旧元数据默认 unsigned,不发生静默提权。 + Connection grant 是实例级宿主状态,安装与回滚均保持原绑定不变;任何后续换绑仍走 PP-M5 独立授权。 + 全 workspace 自动化、`wasm32-wasip2` SDK、advisories/bans/sources 已通过;Windows/macOS/Wayland + 分发实测属于 PP-M10,公开产物继续由 NFR-LEGAL-01 与 Slint license gate 阻断; - TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 @@ -243,7 +251,8 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 9. `feat(cli): complete the Rust plugin author loop`(PP-M4); 10. `feat(connections): add host-owned connection and credential references`(PP-M5); 11. `feat(http): implement the first bounded HTTPS Broker vertical slice`(PP-M5); -12. `feat(examples): add an AI balance monitor reference plugin`(PP-M5/PP-M6)。 +12. `feat(examples): add an AI balance monitor reference plugin`(PP-M5/PP-M6); +13. `feat(distribution): enforce package signing, trusted upgrades and explicit rollback`(PP-M8,已落地)。 这是依赖顺序,不是要求一个 PR 同时完成整个里程碑。每个 PR 必须是一条可审查、可回退、包含失败 路径和联动文档的纵向切片。 diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 45c29e6..30dc8a1 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -64,7 +64,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | | 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | -| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core::install` 提供 InstallMeta 与内容 digest 单源,`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名仍待做 | +| 包工具链 | 已实现,部分验证 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、原子安装与可恢复 journal;`floatile-core` 提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划;`floatile-store` 持久化 publisher/key trust、anti-rollback 水位与原子回滚审计。CLI 受信安装强制签名/撤销/anti-rollback,权限扩大需显式接受;stopped 实例可回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation并保持最高水位。`install.json` 明确记录 `unsigned|trusted`,Shell 对 trusted 安装在持久实例启动与 retry 前按当前 trust 重新验签,对 unsigned 保留显式本地开发降级路径;三宿主分发运行与公开发布仍受平台证据及许可门禁约束。 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | ## 6. 阶段门槛与未决策 diff --git a/docs/security/permission-model.md b/docs/security/permission-model.md index 871e23f..1d12229 100644 --- a/docs/security/permission-model.md +++ b/docs/security/permission-model.md @@ -68,6 +68,19 @@ pub struct Grants { - **实例级收窄**:安装时授予的是「上限」,运行时可被权限管理器收窄,不得放宽。 - **密钥/凭证永远不进入插件**:插件只能持有 credential reference(`cred://`),由宿主注入(见 HTTP Broker)。 +### 2.1 升级权限差异 + +宿主按 capability 的稳定名称和结构化参数比较当前 manifest 与候选 manifest,并输出确定性的 +`Added | Removed | Expanded | Reduced | Unchanged` 差异: + +- 新增 capability,或扩大 key/origin scope、频率、并发、响应大小、超时与存储配额,必须重新确认; +- 移除 capability 或严格收窄全部 scope/配额可以继续升级,不得借降权恢复旧 grant; +- 参数一部分收窄但另一部分扩大时,整体按 `Expanded` 处理;未知 capability 或非法参数直接拒绝; +- 插件 id、publisher 必须保持不变,候选 semver 必须更高,storage migration version 不得倒退。 + +该差异只决定安装/升级是否需要确认,不替代运行时 `PermissionBroker` 决策。Connection 绑定变化仍 +作为独立授权变化重新确认,不能由相同 manifest permission 掩盖。 + ## 3. 决策模型(Check → Decide → Execute) ```