feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -243,3 +243,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- switch_online 不再接受外部 source_id 参数,使用已确认且启用的 Host 信任设置,要求公钥与暂存签名者相同后才联网。测试验证未知来源、禁用和不匹配公钥均在写入安装日志前拒绝;不会因暂存包验签通过而自动信任其签名者。
|
- switch_online 不再接受外部 source_id 参数,使用已确认且启用的 Host 信任设置,要求公钥与暂存签名者相同后才联网。测试验证未知来源、禁用和不匹配公钥均在写入安装日志前拒绝;不会因暂存包验签通过而自动信任其签名者。
|
||||||
- 29 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-trust-settings-tests.log`。覆盖重开恢复、候选确认不匹配、轮换 CAS、禁用持久化、来源身份冲突和在线入口拒绝。
|
- 29 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-trust-settings-tests.log`。覆盖重开恢复、候选确认不匹配、轮换 CAS、禁用持久化、来源身份冲突和在线入口拒绝。
|
||||||
- 来源设置 UI、确认路由、撤销状态持久传播、运行期停用、迁移及真实安装编排仍未完成,整体生产化继续未完成。
|
- 来源设置 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)?;
|
let mut db = Connection::open(database)?;
|
||||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
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"));
|
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
|
||||||
}
|
}
|
||||||
if (1..4).contains(&version) {
|
if (1..5).contains(&version) {
|
||||||
let backup = root.join(format!(
|
let backup = root.join(format!(
|
||||||
"extensions.schema{version}.{}.sqlite3",
|
"extensions.schema{version}.{}.sqlite3",
|
||||||
Uuid::new_v4()
|
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_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_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));
|
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)?;
|
crate::extension_transaction::recover(&mut db)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
root,
|
root,
|
||||||
@@ -356,6 +357,80 @@ impl ExtensionStore {
|
|||||||
params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?;
|
params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?;
|
||||||
Ok(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.
|
/// Online installation gate, using confirmed Host trust settings only.
|
||||||
pub async fn switch_online(
|
pub async fn switch_online(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -389,6 +464,7 @@ impl ExtensionStore {
|
|||||||
if !trusted.enabled || trusted.public_key != key {
|
if !trusted.enabled || trusted.public_key != key {
|
||||||
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
|
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
|
||||||
}
|
}
|
||||||
|
self.check_not_revoked(&source, &release, &key)?;
|
||||||
let client = crate::extension_trust::Client::new(&source)?;
|
let client = crate::extension_trust::Client::new(&source)?;
|
||||||
let checked = client
|
let checked = client
|
||||||
.check(
|
.check(
|
||||||
@@ -400,7 +476,14 @@ impl ExtensionStore {
|
|||||||
},
|
},
|
||||||
&release,
|
&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));
|
verified.push((checked, source, release, key));
|
||||||
}
|
}
|
||||||
self.switch_prepared_inner(operation, vault_id, changes, || {
|
self.switch_prepared_inner(operation, vault_id, changes, || {
|
||||||
@@ -445,6 +528,7 @@ impl ExtensionStore {
|
|||||||
let public: [u8; 32] = key
|
let public: [u8; 32] = key
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
self.check_not_revoked(&source, &release, &public)?;
|
||||||
let (inventory, manifest) = release.verify_package(
|
let (inventory, manifest) = release.verify_package(
|
||||||
&public,
|
&public,
|
||||||
&release.key_id,
|
&release.key_id,
|
||||||
@@ -778,6 +862,66 @@ mod tests {
|
|||||||
archive,
|
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]
|
#[tokio::test]
|
||||||
async fn online_switch_never_trusts_the_staged_signer_implicitly() {
|
async fn online_switch_never_trusts_the_staged_signer_implicitly() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -132,9 +132,6 @@ impl Client {
|
|||||||
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
||||||
}
|
}
|
||||||
let key = matching[0];
|
let key = matching[0];
|
||||||
if key.revoked {
|
|
||||||
return Err(HostError::new("EXTENSION_REVOKED"));
|
|
||||||
}
|
|
||||||
if key.namespace != pin.namespace
|
if key.namespace != pin.namespace
|
||||||
|| release.namespace != pin.namespace
|
|| release.namespace != pin.namespace
|
||||||
|| release.key_id != pin.key_id
|
|| release.key_id != pin.key_id
|
||||||
@@ -142,6 +139,9 @@ impl Client {
|
|||||||
{
|
{
|
||||||
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
||||||
}
|
}
|
||||||
|
if key.revoked {
|
||||||
|
return Err(HostError::new("EXTENSION_KEY_REVOKED"));
|
||||||
|
}
|
||||||
let list: Releases = serde_json::from_value(
|
let list: Releases = serde_json::from_value(
|
||||||
self.json(&format!(
|
self.json(&format!(
|
||||||
"catalog/v1/packages/{}/{}/releases",
|
"catalog/v1/packages/{}/{}/releases",
|
||||||
@@ -163,9 +163,6 @@ impl Client {
|
|||||||
.remove("withdrawn")
|
.remove("withdrawn")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.ok_or_else(unavailable)?;
|
.ok_or_else(unavailable)?;
|
||||||
if withdrawn {
|
|
||||||
return Err(HostError::new("EXTENSION_REVOKED"));
|
|
||||||
}
|
|
||||||
for field in ["release_id", "download_path"] {
|
for field in ["release_id", "download_path"] {
|
||||||
if map
|
if map
|
||||||
.remove(field)
|
.remove(field)
|
||||||
@@ -176,12 +173,15 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let remote: Release = serde_json::from_value(item).map_err(|_| unavailable())?;
|
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());
|
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"));
|
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
|
||||||
}
|
}
|
||||||
|
if found[0].1 {
|
||||||
|
return Err(HostError::new("EXTENSION_RELEASE_WITHDRAWN"));
|
||||||
|
}
|
||||||
let checked = Checked {
|
let checked = Checked {
|
||||||
source: self.source.to_string(),
|
source: self.source.to_string(),
|
||||||
release_hash,
|
release_hash,
|
||||||
@@ -286,6 +286,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.is_ok(), case == "ok", "{case}");
|
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 {
|
if let Ok(mut checked) = result {
|
||||||
checked.matches(&url, &release, &public).unwrap();
|
checked.matches(&url, &release, &public).unwrap();
|
||||||
checked.checked_at = Instant::now() - Duration::from_secs(31);
|
checked.checked_at = Instant::now() - Duration::from_secs(31);
|
||||||
|
|||||||
Reference in New Issue
Block a user