From 67103cbe0e8d922dcd037df4d0c450b3bdddff01 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 21:16:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(extensions):=20=E5=B0=86=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=E7=BB=91=E5=AE=9A=E5=88=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 8 + frontend/src-tauri/src/extension_permit.rs | 253 ++++++++++++++++++ frontend/src-tauri/src/lib.rs | 3 + 3 files changed, 264 insertions(+) create mode 100644 frontend/src-tauri/src/extension_permit.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index a18ca3f..7356e3a 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -199,3 +199,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - schema 2 新增 prepared_packages,记录每个不可变包的准备目录和树摘要。解包完成、记录写入、事务提交三个边界分别注入错误并重开 20 次,最终只有一份有效记录,重放结果稳定。提交前遗留的隔离目录暂不回收,避免误删恢复材料;这不是安装事务或真实断电验收。 - schema 1 升级前 VACUUM INTO 创建独立备份并同步。测试验证原暂存版本不变、备份可读且仍为 schema 1、重复打开不再备份;修复 Windows 只读文件句柄无法执行备份 sync_all 的问题。 - 19 项扩展回归通过,日志 `.build/extension-prepare-tests.log`。当前仍只产生准备结果,未开放扩展执行或自动授予权限;在线信任刷新、依赖集合安装、配置切换、健康检查及回滚继续待完成。 + + +## 增量:Host 运行许可内容绑定 + +- 新增 extension_permit,HMAC-SHA256 绑定来源、命名空间、包/版本、ZIP/展开/签名者摘要、入口、参数数组、环境声明、权限集、Vault、平台、策略版本及到期时间。BTree 容器提供确定性编码,令牌本身仅为认证码;未向 renderer 提供参数或环境声明。 +- Authority 使用系统随机密钥,销毁时清零;到期边界、不同 Authority、全部失效后的旧令牌均拒绝。环境凭据使用 scope 声明,调用方不得向声明填入实际秘密。输入和编码大小有上限,入口穿越、错误环境名和 HTTP 社区来源拒绝。 +- 测试逐字段改成另一份仍合法的声明,确认认证码不再匹配,避免把输入格式拒绝误认为绑定测试通过;另测到期、密钥轮换、路径及体积边界。21 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-permit-tests.log`。 +- 这是许可认证原语,尚未接入安装确认、在线信任、锁定事件、运行沙箱或 broker。它不证明用户已同意,也不授予执行能力;完整 C/D 验收仍未通过。 diff --git a/frontend/src-tauri/src/extension_permit.rs b/frontend/src-tauri/src/extension_permit.rs new file mode 100644 index 0000000..3f72308 --- /dev/null +++ b/frontend/src-tauri/src/extension_permit.rs @@ -0,0 +1,253 @@ +//! Host-only execution permit binding. No IPC caller can mint these permits. +//! The installer must complete user consent and current trust checks before issue. +use crate::workspace::{HostError, Result}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::{BTreeMap, BTreeSet}; +use zeroize::Zeroize; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "value", deny_unknown_fields)] +pub enum Environment { + Literal(String), + // A scoped credential declaration, never resolved secret plaintext. + CredentialScope(String), +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Claims { + pub source: String, + pub namespace: String, + pub package_id: String, + pub version: String, + pub archive_sha256: String, + pub tree_sha256: String, + pub signer_sha256: String, + pub entry: String, + pub arguments: Vec, + pub environment: BTreeMap, + pub permissions: BTreeSet, + pub vault_id: String, + pub platform: String, + pub policy_version: String, + pub expires_at_ms: u64, +} + +/// Opaque authenticator; the Host retains claims separately. No paths, arguments +/// or credential declarations need to be passed to a renderer with the token. +pub struct Permit([u8; 32]); +pub struct Authority { + key: [u8; 32], +} +impl Drop for Authority { + fn drop(&mut self) { + self.key.zeroize(); + } +} +impl Default for Authority { + fn default() -> Self { + let mut key = [0; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + Self { key } + } +} +impl Claims { + fn encoded(&self, now_ms: u64) -> Result> { + let bad = || HostError::new("EXTENSION_PERMIT_INVALID"); + if self.expires_at_ms <= now_ms { + return Err(HostError::new("EXTENSION_PERMIT_EXPIRED")); + } + let url = reqwest::Url::parse(&self.source).map_err(|_| bad())?; + 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() + || self.source.len() > 2048 + { + return Err(bad()); + } + for slug in [&self.namespace, &self.package_id] { + if !(2..=64).contains(&slug.len()) + || !slug.as_bytes()[0].is_ascii_alphanumeric() + || !slug + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(bad()); + } + } + if semver::Version::parse(&self.version).is_err() + || uuid::Uuid::parse_str(&self.vault_id).is_err() + { + return Err(bad()); + } + for digest in [&self.archive_sha256, &self.tree_sha256, &self.signer_sha256] { + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(bad()); + } + } + if self.entry.len() > 1024 + || self.entry.split('/').any(|p| { + p.is_empty() + || p == "." + || p == ".." + || p.contains(['\\', ':']) + || p.chars().any(char::is_control) + }) + { + return Err(bad()); + } + if self.arguments.len() > 128 + || self.environment.len() > 128 + || self.permissions.len() > 128 + || self + .arguments + .iter() + .any(|s| s.len() > 8192 || s.contains('\0')) + || self + .permissions + .iter() + .any(|s| s.is_empty() || s.len() > 256 || s.chars().any(char::is_control)) + || self.platform.is_empty() + || self.platform.len() > 64 + || self.policy_version.is_empty() + || self.policy_version.len() > 128 + { + return Err(bad()); + } + for (name, declaration) in &self.environment { + if name.is_empty() + || name.len() > 128 + || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(bad()); + } + let value = match declaration { + Environment::Literal(v) | Environment::CredentialScope(v) => v, + }; + if value.len() > 8192 || value.contains('\0') { + return Err(bad()); + } + } + let bytes = serde_json::to_vec(self).map_err(|_| bad())?; + if bytes.len() > 256 * 1024 { + return Err(bad()); + } + Ok(bytes) + } +} +impl Authority { + /// Call only after consent and live trust validation. This authenticates the + /// decision; it does not establish sandbox availability or grant broker access. + pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result { + let encoded = claims.encoded(now_ms)?; + let mut mac = Hmac::::new_from_slice(&self.key).expect("HMAC key"); + mac.update(b"OpenNexus execution permit v1\0"); + mac.update(&encoded); + Ok(Permit(mac.finalize().into_bytes().into())) + } + pub fn verify(&self, permit: &Permit, actual: &Claims, now_ms: u64) -> Result<()> { + let encoded = actual.encoded(now_ms)?; + let mut mac = Hmac::::new_from_slice(&self.key).expect("HMAC key"); + mac.update(b"OpenNexus execution permit v1\0"); + mac.update(&encoded); + mac.verify_slice(&permit.0) + .map_err(|_| HostError::new("EXTENSION_PERMIT_MISMATCH")) + } + /// Lock/logout/policy invalidation may discard all permits. Restart creates a + /// fresh key, so an old process token cannot silently revive authorization. + pub fn invalidate_all(&mut self) { + self.key.zeroize(); + rand::rngs::OsRng.fill_bytes(&mut self.key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn claims() -> Claims { + Claims { + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "note-reviewer".into(), + version: "1.0.0".into(), + archive_sha256: "a".repeat(64), + tree_sha256: "b".repeat(64), + signer_sha256: "c".repeat(64), + entry: "main.py".into(), + arguments: vec!["--stdio".into()], + environment: BTreeMap::from([("MODE".into(), Environment::Literal("test".into()))]), + permissions: BTreeSet::from(["notes.read".into()]), + vault_id: uuid::Uuid::new_v4().to_string(), + platform: "windows".into(), + policy_version: "1".into(), + expires_at_ms: 1000, + } + } + #[test] + fn every_claim_is_bound_expiry_and_key_rotation_fail_closed() { + let mut authority = Authority::default(); + let original = claims(); + let token = authority.issue(&original, 100).unwrap(); + authority.verify(&token, &original, 999).unwrap(); + let value = serde_json::to_value(&original).unwrap(); + for field in value.as_object().unwrap().keys() { + let mut changed = value.clone(); + match &mut changed[field] { + serde_json::Value::String(s) => { + *s = match field.as_str() { + "source" => "https://other.example/".into(), + "version" => "1.0.1".into(), + "archive_sha256" | "tree_sha256" | "signer_sha256" => "d".repeat(64), + "vault_id" => uuid::Uuid::new_v4().to_string(), + _ => format!("{s}x"), + }; + } + serde_json::Value::Number(n) => *n = 1001.into(), + serde_json::Value::Array(a) => a.push("extra".into()), + serde_json::Value::Object(o) => { + o.clear(); + } + _ => panic!("unexpected claim type"), + } + let changed: Claims = serde_json::from_value(changed).unwrap(); + changed.encoded(100).unwrap(); + assert!( + authority.verify(&token, &changed, 100).is_err(), + "unbound field {field}" + ); + } + assert!(authority.verify(&token, &original, 1000).is_err()); + assert!(Authority::default().verify(&token, &original, 100).is_err()); + authority.invalidate_all(); + assert!(authority.verify(&token, &original, 100).is_err()); + } + #[test] + fn invalid_paths_environment_and_oversize_decisions_are_rejected() { + let authority = Authority::default(); + for entry in ["../main.py", "C:/main.py", "\\main.py", "a//b", ""] { + let mut c = claims(); + c.entry = entry.into(); + assert!(authority.issue(&c, 100).is_err()); + } + let mut c = claims(); + c.environment + .insert("BAD=KEY".into(), Environment::Literal("v".into())); + assert!(authority.issue(&c, 100).is_err()); + let mut c = claims(); + c.arguments = vec!["x".repeat(8192); 128]; + assert!(authority.issue(&c, 100).is_err()); + let mut c = claims(); + c.source = "http://catalog.example".into(); + assert!(authority.issue(&c, 100).is_err()); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 6c2f481..087ca31 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -39,3 +39,6 @@ pub mod extension_dependencies; #[cfg(feature = "desktop")] pub mod extension_unpack; + +#[cfg(feature = "desktop")] +pub mod extension_permit;