From 0757279f44d7f30cf5ec19d9afe3a3f77b1b0432 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 21:40:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(extensions):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E8=8C=83=E5=9B=B4=E7=9A=84=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E6=92=A4=E9=94=80=E4=B8=8E=E5=8F=91=E8=A1=8C=E6=92=A4=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 9 ++ frontend/src-tauri/src/extension_store.rs | 152 +++++++++++++++++- frontend/src-tauri/src/extension_trust.rs | 25 ++- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index cd74a36..ee98bc2 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -243,3 +243,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - switch_online 不再接受外部 source_id 参数,使用已确认且启用的 Host 信任设置,要求公钥与暂存签名者相同后才联网。测试验证未知来源、禁用和不匹配公钥均在写入安装日志前拒绝;不会因暂存包验签通过而自动信任其签名者。 - 29 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-trust-settings-tests.log`。覆盖重开恢复、候选确认不匹配、轮换 CAS、禁用持久化、来源身份冲突和在线入口拒绝。 - 来源设置 UI、确认路由、撤销状态持久传播、运行期停用、迁移及真实安装编排仍未完成,整体生产化继续未完成。 + + +## 增量:撤销和撤回的持久拒绝 + +- Community 复核区分 EXTENSION_KEY_REVOKED 与 EXTENSION_RELEASE_WITHDRAWN;先匹配固定公钥/身份或完整发行内容,再接受对应撤销状态,避免把另一份元数据的撤回误记到当前包。 +- schema 5 保存持久拒绝记录。键撤销绑定来源、命名空间、键 ID 和公钥摘要;发行撤回绑定来源、命名空间、包与版本。在线入口收到确切撤销后写入,网络失败不会写入;普通信任确认不会清除拒绝记录。 +- 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。 +- 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。 +- 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 D-04 的 5 秒撤销目标通过。恢复旧指针仍可保留数据,但不授予运行许可。 diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index bef1063..63ebc84 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -272,10 +272,10 @@ impl ExtensionStore { let mut db = Connection::open(database)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 4 { + if version > 5 { return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE")); } - if (1..4).contains(&version) { + if (1..5).contains(&version) { let backup = root.join(format!( "extensions.schema{version}.{}.sqlite3", Uuid::new_v4() @@ -290,7 +290,8 @@ impl ExtensionStore { CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT); CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id)); - PRAGMA user_version=4; COMMIT;")?; + CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL); + PRAGMA user_version=5; COMMIT;")?; crate::extension_transaction::recover(&mut db)?; Ok(Self { root, @@ -356,6 +357,80 @@ impl ExtensionStore { params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?; Ok(revision) } + fn block_identity( + source_url: &str, + release: &Release, + public_key: &[u8; 32], + key: bool, + ) -> Result { + let origin = source(source_url)?; + let identity = if key { + serde_json::json!([ + "key", + origin, + release.namespace, + release.key_id, + hash(public_key) + ]) + } else { + serde_json::json!([ + "release", + origin, + release.namespace, + release.package_id, + release.version + ]) + }; + Ok(hash(&serde_json::to_vec(&identity).unwrap())) + } + /// A persisted denial is independent of rollback and renewed source consent. + pub fn check_not_revoked( + &self, + source_url: &str, + release: &Release, + public_key: &[u8; 32], + ) -> Result<()> { + for key in [true, false] { + let identity = Self::block_identity(source_url, release, public_key, key)?; + let reason: Option = self + .db + .query_row( + "SELECT reason FROM extension_blocks WHERE identity=?1", + [identity], + |r| r.get(0), + ) + .optional()?; + if let Some(reason) = reason { + if !matches!( + reason.as_str(), + "EXTENSION_KEY_REVOKED" | "EXTENSION_RELEASE_WITHDRAWN" + ) { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + return Err(HostError::new(&reason)); + } + } + Ok(()) + } + fn remember_revocation( + &mut self, + source_url: &str, + release: &Release, + public_key: &[u8; 32], + code: &str, + ) -> Result<()> { + let key = match code { + "EXTENSION_KEY_REVOKED" => true, + "EXTENSION_RELEASE_WITHDRAWN" => false, + _ => return Ok(()), + }; + let identity = Self::block_identity(source_url, release, public_key, key)?; + self.db.execute( + "INSERT INTO extension_blocks VALUES (?1,?2) ON CONFLICT(identity) DO NOTHING", + params![identity, code], + )?; + Ok(()) + } /// Online installation gate, using confirmed Host trust settings only. pub async fn switch_online( &mut self, @@ -389,6 +464,7 @@ impl ExtensionStore { if !trusted.enabled || trusted.public_key != key { return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED")); } + self.check_not_revoked(&source, &release, &key)?; let client = crate::extension_trust::Client::new(&source)?; let checked = client .check( @@ -400,7 +476,14 @@ impl ExtensionStore { }, &release, ) - .await?; + .await; + let checked = match checked { + Ok(checked) => checked, + Err(error) => { + self.remember_revocation(&source, &release, &key, &error.code)?; + return Err(error); + } + }; verified.push((checked, source, release, key)); } self.switch_prepared_inner(operation, vault_id, changes, || { @@ -445,6 +528,7 @@ impl ExtensionStore { let public: [u8; 32] = key .try_into() .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + self.check_not_revoked(&source, &release, &public)?; let (inventory, manifest) = release.verify_package( &public, &release.key_id, @@ -778,6 +862,66 @@ mod tests { archive, } } + #[test] + fn revocations_survive_restart_and_consent_without_overblocking_other_releases() { + let temp = tempfile::tempdir().unwrap(); + let (release, _, key) = fixture(); + let source = "https://catalog.example/"; + let mut store = ExtensionStore::open(temp.path()).unwrap(); + store + .remember_revocation(source, &release, &key, "EXTENSION_TRUST_UNAVAILABLE") + .unwrap(); + store.check_not_revoked(source, &release, &key).unwrap(); + store + .remember_revocation(source, &release, &key, "EXTENSION_RELEASE_WITHDRAWN") + .unwrap(); + drop(store); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let setting = TrustSetting { + source: source.into(), + source_id: "catalog".into(), + namespace: release.namespace.clone(), + key_id: release.key_id.clone(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + assert_eq!( + store + .check_not_revoked(source, &release, &key) + .unwrap_err() + .code, + "EXTENSION_RELEASE_WITHDRAWN" + ); + let mut next = release.clone(); + next.version = "2.0.0".into(); + store.check_not_revoked(source, &next, &key).unwrap(); + store + .remember_revocation(source, &release, &key, "EXTENSION_KEY_REVOKED") + .unwrap(); + assert_eq!( + store + .check_not_revoked(source, &next, &key) + .unwrap_err() + .code, + "EXTENSION_KEY_REVOKED" + ); + store + .check_not_revoked("https://other.example/", &next, &key) + .unwrap(); + let replacement = SigningKey::from_bytes(&[8; 32]).verifying_key().to_bytes(); + store + .check_not_revoked(source, &next, &replacement) + .unwrap(); + assert!(store + .check_not_revoked(source, &release, &replacement) + .is_err()); + drop(store); + let store = ExtensionStore::open(temp.path()).unwrap(); + assert!(store.check_not_revoked(source, &next, &key).is_err()); + } #[tokio::test] async fn online_switch_never_trusts_the_staged_signer_implicitly() { let temp = tempfile::tempdir().unwrap(); diff --git a/frontend/src-tauri/src/extension_trust.rs b/frontend/src-tauri/src/extension_trust.rs index d9fb356..1ba3238 100644 --- a/frontend/src-tauri/src/extension_trust.rs +++ b/frontend/src-tauri/src/extension_trust.rs @@ -132,9 +132,6 @@ impl Client { return Err(HostError::new("EXTENSION_TRUST_CHANGED")); } let key = matching[0]; - if key.revoked { - return Err(HostError::new("EXTENSION_REVOKED")); - } if key.namespace != pin.namespace || release.namespace != pin.namespace || release.key_id != pin.key_id @@ -142,6 +139,9 @@ impl Client { { return Err(HostError::new("EXTENSION_TRUST_CHANGED")); } + if key.revoked { + return Err(HostError::new("EXTENSION_KEY_REVOKED")); + } let list: Releases = serde_json::from_value( self.json(&format!( "catalog/v1/packages/{}/{}/releases", @@ -163,9 +163,6 @@ impl Client { .remove("withdrawn") .and_then(|v| v.as_bool()) .ok_or_else(unavailable)?; - if withdrawn { - return Err(HostError::new("EXTENSION_REVOKED")); - } for field in ["release_id", "download_path"] { if map .remove(field) @@ -176,12 +173,15 @@ impl Client { } } let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?; - found.push(hash(&serde_json::to_vec(&remote).unwrap())); + found.push((hash(&serde_json::to_vec(&remote).unwrap()), withdrawn)); } let release_hash = hash(&serde_json::to_vec(release).unwrap()); - if found != [release_hash.clone()] { + if found.len() != 1 || found[0].0 != release_hash { return Err(HostError::new("EXTENSION_TRUST_CHANGED")); } + if found[0].1 { + return Err(HostError::new("EXTENSION_RELEASE_WITHDRAWN")); + } let checked = Checked { source: self.source.to_string(), release_hash, @@ -286,6 +286,15 @@ mod tests { ) .await; assert_eq!(result.is_ok(), case == "ok", "{case}"); + if case == "revoked" { + assert_eq!(result.as_ref().err().unwrap().code, "EXTENSION_KEY_REVOKED"); + } + if case == "withdrawn" { + assert_eq!( + result.as_ref().err().unwrap().code, + "EXTENSION_RELEASE_WITHDRAWN" + ); + } if let Ok(mut checked) = result { checked.matches(&url, &release, &public).unwrap(); checked.checked_at = Instant::now() - Duration::from_secs(31);