feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -226,3 +226,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 配置递归拒绝常用秘密字段;writeOnly / x-opennexus-secret 声明禁止持久化对应值,包括条件和组合分支,不能通过 anyOf 的另一个分支绕过。此规则对条件分支采取保守拒绝;秘密应由后续凭据 broker 提供,任意普通字符串不能被自动识别为秘密。
|
- 配置递归拒绝常用秘密字段;writeOnly / x-opennexus-secret 声明禁止持久化对应值,包括条件和组合分支,不能通过 anyOf 的另一个分支绕过。此规则对条件分支采取保守拒绝;秘密应由后续凭据 broker 提供,任意普通字符串不能被自动识别为秘密。
|
||||||
- 测试覆盖嵌套类型、范围、未知属性、错误 schema、引用、深度/体积、秘密声明和组合绕过;安装入口拒绝秘密配置后事务表保持为空。26 项扩展回归通过,日志 `.build/extension-config-tests.log`。
|
- 测试覆盖嵌套类型、范围、未知属性、错误 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。
|
||||||
|
|||||||
@@ -264,6 +264,56 @@ impl ExtensionStore {
|
|||||||
_lock: lock,
|
_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<crate::extension_transaction::Receipt> {
|
||||||
|
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<u8>) = 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
|
/// Atomically selects a prepared group after installer policy checks. This
|
||||||
/// method does not stop processes, validate configuration schemas or issue permits.
|
/// method does not stop processes, validate configuration schemas or issue permits.
|
||||||
pub fn switch_prepared(
|
pub fn switch_prepared(
|
||||||
@@ -271,6 +321,15 @@ impl ExtensionStore {
|
|||||||
operation: &str,
|
operation: &str,
|
||||||
vault_id: &str,
|
vault_id: &str,
|
||||||
changes: &[crate::extension_transaction::Change],
|
changes: &[crate::extension_transaction::Change],
|
||||||
|
) -> Result<crate::extension_transaction::Receipt> {
|
||||||
|
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<crate::extension_transaction::Receipt> {
|
) -> Result<crate::extension_transaction::Receipt> {
|
||||||
use cap_fs_ext::DirExt;
|
use cap_fs_ext::DirExt;
|
||||||
let vault = Uuid::parse_str(vault_id)
|
let vault = Uuid::parse_str(vault_id)
|
||||||
@@ -322,6 +381,7 @@ impl ExtensionStore {
|
|||||||
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
|
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
check_fresh()?;
|
||||||
crate::extension_transaction::switch(&mut self.db, operation, changes)
|
crate::extension_transaction::switch(&mut self.db, operation, changes)
|
||||||
}
|
}
|
||||||
pub fn finish_installation(
|
pub fn finish_installation(
|
||||||
|
|||||||
@@ -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<Key>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Releases {
|
||||||
|
items: Vec<Value>,
|
||||||
|
}
|
||||||
|
fn unavailable() -> HostError {
|
||||||
|
HostError::new("EXTENSION_TRUST_UNAVAILABLE")
|
||||||
|
}
|
||||||
|
impl Client {
|
||||||
|
pub fn new(source: &str) -> Result<Self> {
|
||||||
|
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<Value> {
|
||||||
|
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<Checked> {
|
||||||
|
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}]})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,3 +48,6 @@ pub mod extension_transaction;
|
|||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub mod extension_config;
|
pub mod extension_config;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
pub mod extension_trust;
|
||||||
|
|||||||
Reference in New Issue
Block a user