feat(extensions): 切换前验证已签名配置模式

This commit is contained in:
2026-09-08 21:29:19 +08:00
parent fdc718b18e
commit cc6635c380
6 changed files with 517 additions and 2 deletions
@@ -217,3 +217,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 这仍是安装事务的存储层:配置只要求对象,完整类型 schema/秘密剥离、依赖计划和用户确认复核、在线撤回、停旧实例、沙箱内迁移、真实健康探测及 UI 尚待编排。不能把注入错误重开测试当成 D-03 断电/磁盘满完整矩阵,也不能开启 extensions capability。
- 本轮 Rust desktop 全量回归 78 项通过,2 个既有特殊入口 ignored;实际 Sync 子进程中断恢复由父测试执行通过。全量日志 `.build/extension-transaction-rust-full.log`
## 增量:切换前的签名配置 schema 校验
- switch_prepared 从重新验证的签名清单读取 configuration_schema,配置不能自带替代 schema。未声明时只接受空对象;声明使用 Draft 2020-12、离线 jsonschema 0.55.0(关闭默认网络/文件解析功能),启用格式检查和线性正则引擎。
- schema 与配置各限 64 KiB、4096 节点、24 层;当前拒绝所有引用,包括本地引用,避免 Host 执行递归 schema。高级正则、其他 schema draft 不接受;此限制应在安装 UI 中明确展示,尚无完整配置编辑 UI。
- 配置递归拒绝常用秘密字段;writeOnly / x-opennexus-secret 声明禁止持久化对应值,包括条件和组合分支,不能通过 anyOf 的另一个分支绕过。此规则对条件分支采取保守拒绝;秘密应由后续凭据 broker 提供,任意普通字符串不能被自动识别为秘密。
- 测试覆盖嵌套类型、范围、未知属性、错误 schema、引用、深度/体积、秘密声明和组合绕过;安装入口拒绝秘密配置后事务表保持为空。26 项扩展回归通过,日志 `.build/extension-config-tests.log`
- 这不替代各扩展类型的完整运行配置契约、用户确认、在线信任与沙箱,也没有完成配置迁移/导入秘密的完整流程。整体生产化继续未完成。
+263
View File
@@ -56,7 +56,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
@@ -497,6 +499,12 @@ dependencies = [
"piper",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "brotli"
version = "8.0.4"
@@ -533,6 +541,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -1068,6 +1082,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -1422,6 +1442,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.11"
@@ -1552,6 +1581,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fancy-regex"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e0387578e845beb7a1acff126228499f26cb18edf12919cc513bb863266464"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1637,6 +1677,17 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1685,6 +1736,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
dependencies = [
"num",
"num-bigint",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -1920,9 +1981,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -2149,6 +2212,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashlink"
@@ -2740,6 +2808,59 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49e89c7d35509ab5248f4160addc47098d0f2b09221851bf6234a948f28a70c4"
dependencies = [
"ahash",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex",
"fraction",
"getrandom 0.3.4",
"itoa",
"jsonschema-regex",
"jsonschema-value",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"strum",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fb77178bdde7326e2e414526342ad4e26b602ba5372e53440129dc061bc50b"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dadb285b774085f3f3e67cb48ecce59e5d92028f19a01fe014083a4fbaf7fea"
dependencies = [
"ahash",
"bytecount",
"fraction",
"getrandom 0.3.4",
"num-cmp",
"num-traits",
"serde_json",
"zmij",
]
[[package]]
name = "k256"
version = "0.13.4"
@@ -2958,6 +3079,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mime"
version = "0.3.17"
@@ -3127,6 +3254,7 @@ dependencies = [
"fs2",
"hmac",
"iota_stronghold",
"jsonschema",
"rand 0.8.8",
"reqwest 0.12.28",
"rfd",
@@ -3148,12 +3276,81 @@ dependencies = [
"zip",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3408,6 +3605,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "pango"
version = "0.18.3"
@@ -4007,6 +4210,23 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "referencing"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cc7249156d90d60480d54850c861c37c92f20c6dbb9ae85d98ecca19d81c0b2"
dependencies = [
"ahash",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown 0.17.1",
"itoa",
"micromap",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -4822,6 +5042,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5667,6 +5908,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -5790,6 +6037,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5808,6 +6065,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
+1
View File
@@ -45,6 +45,7 @@ serde-saphyr = { version = "1.2", default-features = false, features = ["deseria
semver = { version = "1", features = ["serde"] }
cap-std = "4.0.2"
cap-fs-ext = "4.0.2"
jsonschema = { version = "0.55", default-features = false }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
+225
View File
@@ -0,0 +1,225 @@
//! Signed, offline configuration schemas. Credentials belong to the vault broker.
use crate::workspace::{HostError, Result};
use serde_json::{json, Value};
fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<()> {
if depth > 24 || *nodes >= 4096 {
return Err(HostError::new("EXTENSION_CONFIG_LIMIT"));
}
*nodes += 1;
match value {
Value::Object(map) => {
for (key, value) in map {
if schema && matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") {
// Recursive/unbounded schema execution is not allowed in the Host.
return Err(HostError::new("EXTENSION_CONFIG_REFERENCE"));
}
if !schema
&& matches!(
key.to_ascii_lowercase().as_str(),
"api_key"
| "apikey"
| "password"
| "token"
| "secret"
| "access_token"
| "refresh_token"
| "authorization"
| "client_secret"
| "private_key"
)
{
return Err(HostError::new("EXTENSION_CONFIG_SECRET"));
}
walk(value, depth + 1, nodes, schema)?;
}
}
Value::Array(items) => {
for item in items {
walk(item, depth + 1, nodes, schema)?;
}
}
_ => {}
}
Ok(())
}
// Examine every applicable schema branch; an alternative branch must not
// turn a secret declaration back into persistable plaintext.
fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
let Some(map) = schema.as_object() else {
return Ok(());
};
if map.get("writeOnly") == Some(&Value::Bool(true))
|| map.get("x-opennexus-secret") == Some(&Value::Bool(true))
{
return Err(HostError::new("EXTENSION_CONFIG_SECRET"));
}
for key in ["allOf", "anyOf", "oneOf"] {
if let Some(items) = map.get(key).and_then(Value::as_array) {
for item in items {
reject_secrets(item, instance)?;
}
}
}
for key in ["if", "then", "else", "not"] {
if let Some(child) = map.get(key) {
reject_secrets(child, instance)?;
}
}
if let Some(properties) = instance.as_object() {
for (name, value) in properties {
if let Some(child) = map.get("unevaluatedProperties") {
reject_secrets(child, value)?;
}
let declared = map.get("properties").and_then(|p| p.get(name));
if let Some(child) = declared {
reject_secrets(child, value)?;
}
let mut matched = false;
if let Some(patterns) = map.get("patternProperties").and_then(Value::as_object) {
for (pattern, child) in patterns {
let matcher = jsonschema::options()
.offline()
.with_pattern_options(jsonschema::PatternOptions::regex())
.build(&json!({"pattern":pattern}))
.map_err(|_| HostError::new("EXTENSION_CONFIG_SCHEMA"))?;
if matcher.is_valid(&Value::String(name.clone())) {
matched = true;
reject_secrets(child, value)?;
}
}
}
if declared.is_none() && !matched {
if let Some(child) = map.get("additionalProperties") {
reject_secrets(child, value)?;
}
}
}
if let Some(dependent) = map.get("dependentSchemas").and_then(Value::as_object) {
for (name, child) in dependent {
if properties.contains_key(name) {
reject_secrets(child, instance)?;
}
}
}
}
if let Some(items) = instance.as_array() {
let prefix = map.get("prefixItems").and_then(Value::as_array);
for (index, item) in items.iter().enumerate() {
if let Some(child) = map.get("unevaluatedItems") {
reject_secrets(child, item)?;
}
if let Some(child) = prefix
.and_then(|p| p.get(index))
.or_else(|| map.get("items"))
{
reject_secrets(child, item)?;
}
if let Some(child) = map.get("contains") {
reject_secrets(child, item)?;
}
}
}
Ok(())
}
/// Schema is read from the verified manifest, never from the proposed configuration.
pub fn validate(manifest: &Value, configuration: &Value) -> Result<()> {
if !configuration.is_object() {
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
}
walk(configuration, 0, &mut 0, false)?;
let schema = manifest
.get("configuration_schema")
.cloned()
.unwrap_or_else(|| json!({"type":"object","additionalProperties":false}));
walk(&schema, 0, &mut 0, true)?;
if schema
.get("$schema")
.is_some_and(|v| v.as_str() != Some("https://json-schema.org/draft/2020-12/schema"))
{
return Err(HostError::new("EXTENSION_CONFIG_SCHEMA"));
}
for value in [&schema, configuration] {
if serde_json::to_vec(value)
.map_err(|_| HostError::new("EXTENSION_CONFIG_INVALID"))?
.len()
> 64 * 1024
{
return Err(HostError::new("EXTENSION_CONFIG_LIMIT"));
}
}
let validator = jsonschema::options()
.offline()
.with_draft(jsonschema::Draft::Draft202012)
.with_pattern_options(jsonschema::PatternOptions::regex())
.should_validate_formats(true)
.should_ignore_unknown_formats(false)
.build(&schema)
.map_err(|_| HostError::new("EXTENSION_CONFIG_SCHEMA"))?;
if !validator.is_valid(configuration) {
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
}
reject_secrets(&schema, configuration)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signed_schema_checks_nested_types_bounds_and_unknown_properties() {
let manifest = json!({"configuration_schema":{"type":"object","required":["count"],"additionalProperties":false,
"properties":{"count":{"type":"integer","minimum":1,"maximum":5},"labels":{"type":"array","maxItems":2,"items":{"type":"string","pattern":"^[a-z]+$"}}}}});
validate(&manifest, &json!({"count":2,"labels":["valid"]})).unwrap();
for config in [
json!({}),
json!({"count":0}),
json!({"count":"2"}),
json!({"count":2,"extra":true}),
json!({"count":2,"labels":["INVALID"]}),
] {
assert!(validate(&manifest, &config).is_err());
}
validate(&json!({}), &json!({})).unwrap();
assert!(validate(&json!({}), &json!({"undeclared":true})).is_err());
}
#[test]
fn secrets_references_and_excessive_work_are_refused_without_values_in_errors() {
let secret = "do-not-print-this-value";
for field in ["password", "TOKEN", "api_key", "client_secret"] {
let err = validate(
&json!({"configuration_schema":true}),
&json!({"nested":[{field:secret}]}),
)
.unwrap_err();
assert_eq!(err.code, "EXTENSION_CONFIG_SECRET");
assert!(!format!("{err:?}").contains(secret));
}
for mark in ["writeOnly", "x-opennexus-secret"] {
let schema = json!({"configuration_schema":{"properties":{"custom":{"type":"string",mark:true}}}});
assert!(validate(&schema, &json!({"custom":secret})).is_err());
validate(&schema, &json!({})).unwrap();
}
assert!(validate(&json!({"configuration_schema":{"anyOf":[{"properties":{"custom":{"writeOnly":true}}},true]}}),&json!({"custom":secret})).is_err());
for reference in ["file:///etc/passwd", "https://example.com/schema", "#"] {
assert!(validate(
&json!({"configuration_schema":{"$ref":reference}}),
&json!({})
)
.is_err());
}
assert!(validate(&json!({"configuration_schema":{"type":42}}), &json!({})).is_err());
let mut deep = json!(true);
for _ in 0..25 {
deep = json!({"nested":deep});
}
assert!(validate(&json!({"configuration_schema":true}), &deep).is_err());
assert!(validate(
&json!({"configuration_schema":true}),
&json!({"value":"a".repeat(65536)})
)
.is_err());
}
}
+16 -2
View File
@@ -290,7 +290,7 @@ impl ExtensionStore {
let public: [u8; 32] = key
.try_into()
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let (inventory, _) = release.verify_package(
let (inventory, manifest) = release.verify_package(
&public,
&release.key_id,
&release.namespace,
@@ -298,6 +298,7 @@ impl ExtensionStore {
false,
&self.archive(&change.target.package_key)?,
)?;
crate::extension_config::validate(&manifest, &change.target.configuration)?;
let slot = hash(
&serde_json::to_vec(&(&vault, &source, &release.namespace, &release.package_id))
.unwrap(),
@@ -663,7 +664,7 @@ mod tests {
package_key: staged.package_key,
directory: prepared.directory.clone(),
tree_sha256: prepared.tree_sha256,
configuration: serde_json::json!({"review":true}),
configuration: serde_json::json!({}),
},
expected_revision: None,
}];
@@ -674,6 +675,19 @@ mod tests {
&changes
)
.is_err());
let mut invalid = changes.clone();
invalid[0].target.configuration = serde_json::json!({"password":"never-persist-this"});
assert!(store
.switch_prepared(&Uuid::new_v4().to_string(), &vault, &invalid)
.is_err());
assert_eq!(
store
.db
.query_row("SELECT COUNT(*) FROM extension_transactions", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
let operation = Uuid::new_v4().to_string();
store.switch_prepared(&operation, &vault, &changes).unwrap();
assert!(store
+3
View File
@@ -45,3 +45,6 @@ pub mod extension_permit;
#[cfg(feature = "desktop")]
pub mod extension_transaction;
#[cfg(feature = "desktop")]
pub mod extension_config;