diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index c137658..306648f 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -226,3 +226,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 配置递归拒绝常用秘密字段;writeOnly / x-opennexus-secret 声明禁止持久化对应值,包括条件和组合分支,不能通过 anyOf 的另一个分支绕过。此规则对条件分支采取保守拒绝;秘密应由后续凭据 broker 提供,任意普通字符串不能被自动识别为秘密。 - 测试覆盖嵌套类型、范围、未知属性、错误 schema、引用、深度/体积、秘密声明和组合绕过;安装入口拒绝秘密配置后事务表保持为空。26 项扩展回归通过,日志 `.build/extension-config-tests.log`。 - 这不替代各扩展类型的完整运行配置契约、用户确认、在线信任与沙箱,也没有完成配置迁移/导入秘密的完整流程。整体生产化继续未完成。 + + +## 增量:社区在线信任复核入口 + +- 新增 HTTPS Community 客户端,复核 source_id、原固定公钥/命名空间、键撤销、发行撤回及完整发行内容。重复版本、键替换、未知响应、非 200(含重定向)、离线均拒绝;不接受刷新时自动信任新键。每请求 15 秒、JSON 4 MiB、集合 4096 项上限。 +- Checked 凭据由客户端内部构造,绑定规范来源、完整发行摘要和公钥摘要,单调时钟 30 秒过期;不序列化持久保存。switch_online 限同源组,所有准备目录与配置检查后、写入事务前再次复核时效。 +- 受控真实 HTTP socket 测试覆盖正常、撤销、键替换、撤回、内容变化、重复版本、503、302 和过期;HTTP 仅测试内部替换 URL,公开构造器只允许 HTTPS。27 项扩展回归与 Clippy 全目标检查通过,日志 `.build/extension-trust-tests.log`。 +- 尚未取得真实 HTTPS Community 部署验收;固定键来源仍要求 Host 信任设置提供,尚无设置 UI/轮换确认和运行期撤销停止循环。底层 switch_prepared 仍是未接 IPC 的内部安装存储原语,正式入口应调用 switch_online;离线本地包需独立信任流程。不能据此开启扩展执行或通过 D-04。 diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 654b028..ede5b4e 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -264,6 +264,56 @@ impl ExtensionStore { _lock: lock, }) } + /// Online installation gate. The source identity and staged signing key must + /// originate from Host trust settings; this never accepts a replacement key. + pub async fn switch_online( + &mut self, + operation: &str, + vault_id: &str, + source_id: &str, + changes: &[crate::extension_transaction::Change], + ) -> Result { + if changes.is_empty() || changes.len() > 200 { + return Err(HostError::new("EXTENSION_TRANSACTION_INVALID")); + } + let mut verified = Vec::new(); + let mut origin = None; + for change in changes { + let (source, json, key): (String, String, Vec) = self.db.query_row( + "SELECT source,release,signer FROM versions WHERE package_key=?1", + [&change.target.package_key], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + )?; + if origin.as_ref().is_some_and(|s| s != &source) { + return Err(HostError::new("EXTENSION_SOURCE_INVALID")); + } + origin = Some(source.clone()); + let release: Release = serde_json::from_str(&json) + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let key: [u8; 32] = key + .try_into() + .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?; + let client = crate::extension_trust::Client::new(&source)?; + let checked = client + .check( + crate::extension_trust::Pin { + source_id, + key_id: &release.key_id, + namespace: &release.namespace, + public_key: &key, + }, + &release, + ) + .await?; + verified.push((checked, source, release, key)); + } + self.switch_prepared_inner(operation, vault_id, changes, || { + for (checked, source, release, key) in &verified { + checked.matches(source, release, key)?; + } + Ok(()) + }) + } /// Atomically selects a prepared group after installer policy checks. This /// method does not stop processes, validate configuration schemas or issue permits. pub fn switch_prepared( @@ -271,6 +321,15 @@ impl ExtensionStore { operation: &str, vault_id: &str, changes: &[crate::extension_transaction::Change], + ) -> Result { + self.switch_prepared_inner(operation, vault_id, changes, || Ok(())) + } + fn switch_prepared_inner( + &mut self, + operation: &str, + vault_id: &str, + changes: &[crate::extension_transaction::Change], + check_fresh: impl FnOnce() -> Result<()>, ) -> Result { use cap_fs_ext::DirExt; let vault = Uuid::parse_str(vault_id) @@ -322,6 +381,7 @@ impl ExtensionStore { return Err(HostError::new("EXTENSION_STORE_CORRUPT")); } } + check_fresh()?; crate::extension_transaction::switch(&mut self.db, operation, changes) } pub fn finish_installation( diff --git a/frontend/src-tauri/src/extension_trust.rs b/frontend/src-tauri/src/extension_trust.rs new file mode 100644 index 0000000..81cef12 --- /dev/null +++ b/frontend/src-tauri/src/extension_trust.rs @@ -0,0 +1,295 @@ +//! Fresh Community state checked against Host-pinned keys; never TOFU on refresh. +use crate::{ + extension_package::Release, + workspace::{hash, HostError, Result}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; +use serde_json::Value; +use std::time::{Duration, Instant}; + +pub struct Client { + source: reqwest::Url, + http: reqwest::Client, +} +pub struct Pin<'a> { + pub source_id: &'a str, + pub key_id: &'a str, + pub namespace: &'a str, + pub public_key: &'a [u8; 32], +} +pub struct Checked { + source: String, + release_hash: String, + key_hash: String, + checked_at: Instant, +} +impl Checked { + /// Monotonic freshness avoids a wall-clock rollback extending validity. + pub fn matches(&self, source: &str, release: &Release, key: &[u8; 32]) -> Result<()> { + if self.checked_at.elapsed() > Duration::from_secs(30) { + return Err(HostError::new("EXTENSION_TRUST_STALE")); + } + let url = + reqwest::Url::parse(source).map_err(|_| HostError::new("EXTENSION_SOURCE_INVALID"))?; + if url.as_str() != self.source + || hash(&serde_json::to_vec(release).unwrap()) != self.release_hash + || hash(key) != self.key_hash + { + return Err(HostError::new("EXTENSION_TRUST_CHANGED")); + } + Ok(()) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Key { + key_id: String, + namespace: String, + public_key: String, + revoked: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Source { + schema_version: u32, + source_id: String, + keys: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Releases { + items: Vec, +} +fn unavailable() -> HostError { + HostError::new("EXTENSION_TRUST_UNAVAILABLE") +} +impl Client { + pub fn new(source: &str) -> Result { + let mut url = + reqwest::Url::parse(source).map_err(|_| HostError::new("EXTENSION_SOURCE_INVALID"))?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || source.len() > 2048 + { + return Err(HostError::new("EXTENSION_SOURCE_INVALID")); + } + let path = format!("{}/", url.path().trim_end_matches('/')); + url.set_path(&path); + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| unavailable())?; + Ok(Self { source: url, http }) + } + async fn json(&self, path: &str) -> Result { + let url = self.source.join(path).map_err(|_| unavailable())?; + let mut response = self + .http + .get(url) + .header("Cache-Control", "no-cache, no-store") + .send() + .await + .map_err(|_| unavailable())?; + if response.status() != reqwest::StatusCode::OK + || response + .content_length() + .is_some_and(|n| n > 4 * 1024 * 1024) + { + return Err(unavailable()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? { + if bytes.len() + chunk.len() > 4 * 1024 * 1024 { + return Err(unavailable()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| unavailable()) + } + pub async fn check(&self, pin: Pin<'_>, release: &Release) -> Result { + release.validate()?; + let started = Instant::now(); + let source: Source = serde_json::from_value(self.json("catalog/v1/sources").await?) + .map_err(|_| unavailable())?; + if source.schema_version != 1 + || source.source_id != pin.source_id + || source.keys.len() > 4096 + { + return Err(HostError::new("EXTENSION_TRUST_CHANGED")); + } + let matching: Vec<_> = source + .keys + .iter() + .filter(|k| k.key_id == pin.key_id) + .collect(); + if matching.len() != 1 { + 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 + || STANDARD.decode(&key.public_key).ok().as_deref() != Some(pin.public_key.as_slice()) + { + return Err(HostError::new("EXTENSION_TRUST_CHANGED")); + } + let list: Releases = serde_json::from_value( + self.json(&format!( + "catalog/v1/packages/{}/{}/releases", + release.namespace, release.package_id + )) + .await?, + ) + .map_err(|_| unavailable())?; + if list.items.len() > 4096 { + return Err(unavailable()); + } + let mut found = Vec::new(); + for mut item in list.items { + if item.get("version").and_then(Value::as_str) != Some(release.version.as_str()) { + continue; + } + let map = item.as_object_mut().ok_or_else(unavailable)?; + let withdrawn = map + .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) + .and_then(|v| v.as_str().map(str::to_owned)) + .is_none() + { + return Err(unavailable()); + } + } + let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?; + found.push(hash(&serde_json::to_vec(&remote).unwrap())); + } + let release_hash = hash(&serde_json::to_vec(release).unwrap()); + if found != [release_hash.clone()] { + return Err(HostError::new("EXTENSION_TRUST_CHANGED")); + } + let checked = Checked { + source: self.source.to_string(), + release_hash, + key_hash: hash(pin.public_key), + checked_at: started, + }; + checked.matches(self.source.as_str(), release, pin.public_key)?; + Ok(checked) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + #[tokio::test] + async fn live_responses_require_pinned_key_exact_release_and_no_revocation() { + let mut fixture: Value = serde_json::from_str(include_str!( + "../../src/services/fixtures/community-python-vector.json" + )) + .unwrap(); + let public: [u8; 32] = STANDARD + .decode(fixture["key"]["public_key"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + let display = fixture["release"].clone(); + for field in ["release_id", "withdrawn", "download_path"] { + fixture["release"].as_object_mut().unwrap().remove(field); + } + let release: Release = serde_json::from_value(fixture["release"].clone()).unwrap(); + for case in [ + "ok", + "revoked", + "rotated", + "withdrawn", + "changed", + "duplicate", + "offline", + "redirect", + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let mut source = json_source(&public); + let mut remote = display.clone(); + if case == "revoked" { + source["keys"][0]["revoked"] = true.into(); + } + if case == "rotated" { + source["keys"][0]["public_key"] = STANDARD.encode([9; 32]).into(); + } + if case == "withdrawn" { + remote["withdrawn"] = true.into(); + } + if case == "changed" { + remote["description"] = "changed".into(); + } + let list = if case == "duplicate" { + serde_json::json!({"items":[remote.clone(),remote]}) + } else { + serde_json::json!({"items":[remote]}) + }; + let responses = if matches!(case, "revoked" | "rotated" | "offline" | "redirect") { + vec![source] + } else { + vec![source, list] + }; + let worker = std::thread::spawn(move || { + for body in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = [0; 8192]; + stream.read(&mut bytes).unwrap(); + let body = body.to_string(); + let status = match case { + "offline" => "503 Unavailable", + "redirect" => "302 Found", + _ => "200 OK", + }; + write!(stream,"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).unwrap(); + } + }); + let mut client = Client::new("https://catalog.example/").unwrap(); + client.source = reqwest::Url::parse(&url).unwrap(); + let result = client + .check( + Pin { + source_id: "fixture", + key_id: "test-key", + namespace: "examples", + public_key: &public, + }, + &release, + ) + .await; + assert_eq!(result.is_ok(), case == "ok", "{case}"); + if let Ok(mut checked) = result { + checked.matches(&url, &release, &public).unwrap(); + checked.checked_at = Instant::now() - Duration::from_secs(31); + assert!(checked.matches(&url, &release, &public).is_err()); + } + worker.join().unwrap(); + } + assert!(Client::new("http://catalog.example").is_err()); + } + fn json_source(public: &[u8; 32]) -> Value { + serde_json::json!({"schema_version":1,"source_id":"fixture","keys":[{"key_id":"test-key","namespace":"examples","public_key":STANDARD.encode(public),"revoked":false}]}) + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 85f8e9d..80c5733 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -48,3 +48,6 @@ pub mod extension_transaction; #[cfg(feature = "desktop")] pub mod extension_config; + +#[cfg(feature = "desktop")] +pub mod extension_trust;