feat(extensions): 在线切换前重新检查已固定的社区信任

This commit is contained in:
2026-09-08 21:33:19 +08:00
parent cc6635c380
commit 888ade6b8f
4 changed files with 366 additions and 0 deletions
@@ -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。
+60
View File
@@ -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<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
/// 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<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> {
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(
+295
View File
@@ -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}]})
}
}
+3
View File
@@ -48,3 +48,6 @@ pub mod extension_transaction;
#[cfg(feature = "desktop")]
pub mod extension_config;
#[cfg(feature = "desktop")]
pub mod extension_trust;