feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -199,3 +199,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- schema 2 新增 prepared_packages,记录每个不可变包的准备目录和树摘要。解包完成、记录写入、事务提交三个边界分别注入错误并重开 20 次,最终只有一份有效记录,重放结果稳定。提交前遗留的隔离目录暂不回收,避免误删恢复材料;这不是安装事务或真实断电验收。
|
- schema 2 新增 prepared_packages,记录每个不可变包的准备目录和树摘要。解包完成、记录写入、事务提交三个边界分别注入错误并重开 20 次,最终只有一份有效记录,重放结果稳定。提交前遗留的隔离目录暂不回收,避免误删恢复材料;这不是安装事务或真实断电验收。
|
||||||
- schema 1 升级前 VACUUM INTO 创建独立备份并同步。测试验证原暂存版本不变、备份可读且仍为 schema 1、重复打开不再备份;修复 Windows 只读文件句柄无法执行备份 sync_all 的问题。
|
- schema 1 升级前 VACUUM INTO 创建独立备份并同步。测试验证原暂存版本不变、备份可读且仍为 schema 1、重复打开不再备份;修复 Windows 只读文件句柄无法执行备份 sync_all 的问题。
|
||||||
- 19 项扩展回归通过,日志 `.build/extension-prepare-tests.log`。当前仍只产生准备结果,未开放扩展执行或自动授予权限;在线信任刷新、依赖集合安装、配置切换、健康检查及回滚继续待完成。
|
- 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 验收仍未通过。
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub environment: BTreeMap<String, Environment>,
|
||||||
|
pub permissions: BTreeSet<String>,
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Permit> {
|
||||||
|
let encoded = claims.encoded(now_ms)?;
|
||||||
|
let mut mac = Hmac::<Sha256>::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::<Sha256>::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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,3 +39,6 @@ pub mod extension_dependencies;
|
|||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod extension_unpack;
|
pub mod extension_unpack;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
pub mod extension_permit;
|
||||||
|
|||||||
Reference in New Issue
Block a user