feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -243,3 +243,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- switch_online 不再接受外部 source_id 参数,使用已确认且启用的 Host 信任设置,要求公钥与暂存签名者相同后才联网。测试验证未知来源、禁用和不匹配公钥均在写入安装日志前拒绝;不会因暂存包验签通过而自动信任其签名者。
|
||||
- 29 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-trust-settings-tests.log`。覆盖重开恢复、候选确认不匹配、轮换 CAS、禁用持久化、来源身份冲突和在线入口拒绝。
|
||||
- 来源设置 UI、确认路由、撤销状态持久传播、运行期停用、迁移及真实安装编排仍未完成,整体生产化继续未完成。
|
||||
|
||||
|
||||
## 增量:撤销和撤回的持久拒绝
|
||||
|
||||
- Community 复核区分 EXTENSION_KEY_REVOKED 与 EXTENSION_RELEASE_WITHDRAWN;先匹配固定公钥/身份或完整发行内容,再接受对应撤销状态,避免把另一份元数据的撤回误记到当前包。
|
||||
- schema 5 保存持久拒绝记录。键撤销绑定来源、命名空间、键 ID 和公钥摘要;发行撤回绑定来源、命名空间、包与版本。在线入口收到确切撤销后写入,网络失败不会写入;普通信任确认不会清除拒绝记录。
|
||||
- 在线及底层准备切换均检查持久拒绝。重启后仍拒绝,发行撤回不影响其他版本,旧键撤销不自动禁止另一个显式确认的新公钥,但新公钥也不能复活已撤回版本。
|
||||
- 30 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-revocation-tests.log`。测试覆盖持久性、信任确认后仍拒绝、不同版本/来源/公钥范围和网络失败不污染拒绝记录。
|
||||
- 尚未接入运行中实例和工具注册表的撤销轮询/停止,不能据此声明 D-04 的 5 秒撤销目标通过。恢复旧指针仍可保留数据,但不授予运行许可。
|
||||
|
||||
@@ -272,10 +272,10 @@ impl ExtensionStore {
|
||||
let mut db = Connection::open(database)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version > 4 {
|
||||
if version > 5 {
|
||||
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..4).contains(&version) {
|
||||
if (1..5).contains(&version) {
|
||||
let backup = root.join(format!(
|
||||
"extensions.schema{version}.{}.sqlite3",
|
||||
Uuid::new_v4()
|
||||
@@ -290,7 +290,8 @@ impl ExtensionStore {
|
||||
CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT);
|
||||
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
|
||||
PRAGMA user_version=4; COMMIT;")?;
|
||||
CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL);
|
||||
PRAGMA user_version=5; COMMIT;")?;
|
||||
crate::extension_transaction::recover(&mut db)?;
|
||||
Ok(Self {
|
||||
root,
|
||||
@@ -356,6 +357,80 @@ impl ExtensionStore {
|
||||
params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?;
|
||||
Ok(revision)
|
||||
}
|
||||
fn block_identity(
|
||||
source_url: &str,
|
||||
release: &Release,
|
||||
public_key: &[u8; 32],
|
||||
key: bool,
|
||||
) -> Result<String> {
|
||||
let origin = source(source_url)?;
|
||||
let identity = if key {
|
||||
serde_json::json!([
|
||||
"key",
|
||||
origin,
|
||||
release.namespace,
|
||||
release.key_id,
|
||||
hash(public_key)
|
||||
])
|
||||
} else {
|
||||
serde_json::json!([
|
||||
"release",
|
||||
origin,
|
||||
release.namespace,
|
||||
release.package_id,
|
||||
release.version
|
||||
])
|
||||
};
|
||||
Ok(hash(&serde_json::to_vec(&identity).unwrap()))
|
||||
}
|
||||
/// A persisted denial is independent of rollback and renewed source consent.
|
||||
pub fn check_not_revoked(
|
||||
&self,
|
||||
source_url: &str,
|
||||
release: &Release,
|
||||
public_key: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
for key in [true, false] {
|
||||
let identity = Self::block_identity(source_url, release, public_key, key)?;
|
||||
let reason: Option<String> = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT reason FROM extension_blocks WHERE identity=?1",
|
||||
[identity],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(reason) = reason {
|
||||
if !matches!(
|
||||
reason.as_str(),
|
||||
"EXTENSION_KEY_REVOKED" | "EXTENSION_RELEASE_WITHDRAWN"
|
||||
) {
|
||||
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
|
||||
}
|
||||
return Err(HostError::new(&reason));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remember_revocation(
|
||||
&mut self,
|
||||
source_url: &str,
|
||||
release: &Release,
|
||||
public_key: &[u8; 32],
|
||||
code: &str,
|
||||
) -> Result<()> {
|
||||
let key = match code {
|
||||
"EXTENSION_KEY_REVOKED" => true,
|
||||
"EXTENSION_RELEASE_WITHDRAWN" => false,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let identity = Self::block_identity(source_url, release, public_key, key)?;
|
||||
self.db.execute(
|
||||
"INSERT INTO extension_blocks VALUES (?1,?2) ON CONFLICT(identity) DO NOTHING",
|
||||
params![identity, code],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Online installation gate, using confirmed Host trust settings only.
|
||||
pub async fn switch_online(
|
||||
&mut self,
|
||||
@@ -389,6 +464,7 @@ impl ExtensionStore {
|
||||
if !trusted.enabled || trusted.public_key != key {
|
||||
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
|
||||
}
|
||||
self.check_not_revoked(&source, &release, &key)?;
|
||||
let client = crate::extension_trust::Client::new(&source)?;
|
||||
let checked = client
|
||||
.check(
|
||||
@@ -400,7 +476,14 @@ impl ExtensionStore {
|
||||
},
|
||||
&release,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
let checked = match checked {
|
||||
Ok(checked) => checked,
|
||||
Err(error) => {
|
||||
self.remember_revocation(&source, &release, &key, &error.code)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
verified.push((checked, source, release, key));
|
||||
}
|
||||
self.switch_prepared_inner(operation, vault_id, changes, || {
|
||||
@@ -445,6 +528,7 @@ impl ExtensionStore {
|
||||
let public: [u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||
self.check_not_revoked(&source, &release, &public)?;
|
||||
let (inventory, manifest) = release.verify_package(
|
||||
&public,
|
||||
&release.key_id,
|
||||
@@ -778,6 +862,66 @@ mod tests {
|
||||
archive,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn revocations_survive_restart_and_consent_without_overblocking_other_releases() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (release, _, key) = fixture();
|
||||
let source = "https://catalog.example/";
|
||||
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||
store
|
||||
.remember_revocation(source, &release, &key, "EXTENSION_TRUST_UNAVAILABLE")
|
||||
.unwrap();
|
||||
store.check_not_revoked(source, &release, &key).unwrap();
|
||||
store
|
||||
.remember_revocation(source, &release, &key, "EXTENSION_RELEASE_WITHDRAWN")
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||
let setting = TrustSetting {
|
||||
source: source.into(),
|
||||
source_id: "catalog".into(),
|
||||
namespace: release.namespace.clone(),
|
||||
key_id: release.key_id.clone(),
|
||||
public_key: key,
|
||||
enabled: true,
|
||||
};
|
||||
store
|
||||
.confirm_trust(&setting, None, &setting.fingerprint().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.check_not_revoked(source, &release, &key)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"EXTENSION_RELEASE_WITHDRAWN"
|
||||
);
|
||||
let mut next = release.clone();
|
||||
next.version = "2.0.0".into();
|
||||
store.check_not_revoked(source, &next, &key).unwrap();
|
||||
store
|
||||
.remember_revocation(source, &release, &key, "EXTENSION_KEY_REVOKED")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.check_not_revoked(source, &next, &key)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"EXTENSION_KEY_REVOKED"
|
||||
);
|
||||
store
|
||||
.check_not_revoked("https://other.example/", &next, &key)
|
||||
.unwrap();
|
||||
let replacement = SigningKey::from_bytes(&[8; 32]).verifying_key().to_bytes();
|
||||
store
|
||||
.check_not_revoked(source, &next, &replacement)
|
||||
.unwrap();
|
||||
assert!(store
|
||||
.check_not_revoked(source, &release, &replacement)
|
||||
.is_err());
|
||||
drop(store);
|
||||
let store = ExtensionStore::open(temp.path()).unwrap();
|
||||
assert!(store.check_not_revoked(source, &next, &key).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn online_switch_never_trusts_the_staged_signer_implicitly() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -132,9 +132,6 @@ impl Client {
|
||||
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
|
||||
@@ -142,6 +139,9 @@ impl Client {
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
||||
}
|
||||
if key.revoked {
|
||||
return Err(HostError::new("EXTENSION_KEY_REVOKED"));
|
||||
}
|
||||
let list: Releases = serde_json::from_value(
|
||||
self.json(&format!(
|
||||
"catalog/v1/packages/{}/{}/releases",
|
||||
@@ -163,9 +163,6 @@ impl Client {
|
||||
.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)
|
||||
@@ -176,12 +173,15 @@ impl Client {
|
||||
}
|
||||
}
|
||||
let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?;
|
||||
found.push(hash(&serde_json::to_vec(&remote).unwrap()));
|
||||
found.push((hash(&serde_json::to_vec(&remote).unwrap()), withdrawn));
|
||||
}
|
||||
let release_hash = hash(&serde_json::to_vec(release).unwrap());
|
||||
if found != [release_hash.clone()] {
|
||||
if found.len() != 1 || found[0].0 != release_hash {
|
||||
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
||||
}
|
||||
if found[0].1 {
|
||||
return Err(HostError::new("EXTENSION_RELEASE_WITHDRAWN"));
|
||||
}
|
||||
let checked = Checked {
|
||||
source: self.source.to_string(),
|
||||
release_hash,
|
||||
@@ -286,6 +286,15 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.is_ok(), case == "ok", "{case}");
|
||||
if case == "revoked" {
|
||||
assert_eq!(result.as_ref().err().unwrap().code, "EXTENSION_KEY_REVOKED");
|
||||
}
|
||||
if case == "withdrawn" {
|
||||
assert_eq!(
|
||||
result.as_ref().err().unwrap().code,
|
||||
"EXTENSION_RELEASE_WITHDRAWN"
|
||||
);
|
||||
}
|
||||
if let Ok(mut checked) = result {
|
||||
checked.matches(&url, &release, &public).unwrap();
|
||||
checked.checked_at = Instant::now() - Duration::from_secs(31);
|
||||
|
||||
Reference in New Issue
Block a user