fix(community): 原子确认多密钥来源信任

This commit is contained in:
2026-09-08 22:12:49 +08:00
parent 86b9d06dcc
commit 7bdc66ef75
9 changed files with 188 additions and 5 deletions
@@ -304,3 +304,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- DesktopPackages 对话框允许输入根包配置,调用 Host 完整预览,展示依赖拓扑顺序、请求权限和检查后的配置。当前 Vault、请求代次和选中根包共同约束返回结果,切换 Vault 关闭预览并丢弃迟到响应。 - DesktopPackages 对话框允许输入根包配置,调用 Host 完整预览,展示依赖拓扑顺序、请求权限和检查后的配置。当前 Vault、请求代次和选中根包共同约束返回结果,切换 Vault 关闭预览并丢弃迟到响应。
- 5 项前端针对性测试、两套 TypeScript 项目检查和 Rust 全目标 Clippy -D warnings 通过。新增组件测试验证使用 Host 列表、精确 Vault/配置参数、只发预览命令,以及切库后不显示旧权限结果。 - 5 项前端针对性测试、两套 TypeScript 项目检查和 Rust 全目标 Clippy -D warnings 通过。新增组件测试验证使用 Host 列表、精确 Vault/配置参数、只发预览命令,以及切库后不显示旧权限结果。
- 页面明确安装执行尚未开放;依赖包配置编辑、自动取得缺失依赖、正式确认执行、沙箱与真实端到端验收仍未完成。整体生产化目标继续进行。 - 页面明确安装执行尚未开放;依赖包配置编辑、自动取得缺失依赖、正式确认执行、沙箱与真实端到端验收仍未完成。整体生产化目标继续进行。
## 修复:多公钥来源的原子确认
- 新增 extension_trust_confirm_group,前端一次提交整组 Host 复核凭据。Host 先核对数量、重复凭据、过期与摘要,再由安装库 savepoint 一次确认同来源全部键;不同来源/来源身份或重复键拒绝。
- 任一后续键发生 revision 冲突时,前面已执行的更新全部回滚;确认凭据只在整组成功后一起消耗。此变更替代此前文档中逐键确认可能部分成功的流程。
- 34 项扩展回归、2 项 Host 确认测试、3 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过。日志 `.build/extension-trust-group-tests.log`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。
- 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。
+1
View File
@@ -18,6 +18,7 @@ fn main() {
"host_capabilities", "host_capabilities",
"extension_trust_review", "extension_trust_review",
"extension_trust_confirm", "extension_trust_confirm",
"extension_trust_confirm_group",
"extension_install_preview", "extension_install_preview",
"extension_stage", "extension_stage",
"extension_stage_prepare", "extension_stage_prepare",
+2 -1
View File
@@ -57,6 +57,7 @@
"allow-extension-stage-prepare", "allow-extension-stage-prepare",
"allow-extension-stage-cancel", "allow-extension-stage-cancel",
"allow-extension-stage-status", "allow-extension-stage-status",
"allow-extension-staged" "allow-extension-staged",
"allow-extension-trust-confirm-group"
] ]
} }
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-extension-trust-confirm-group"
description = "Enables the extension_trust_confirm_group command without any pre-configured scope."
commands.allow = ["extension_trust_confirm_group"]
[[permission]]
identifier = "deny-extension-trust-confirm-group"
description = "Denies the extension_trust_confirm_group command without any pre-configured scope."
commands.deny = ["extension_trust_confirm_group"]
@@ -247,10 +247,88 @@ pub async fn extension_staged(
.map_err(|_| "EXTENSION_LIST_FAILED".to_string())? .map_err(|_| "EXTENSION_LIST_FAILED".to_string())?
} }
#[tauri::command]
pub fn extension_trust_confirm_group(
window: WebviewWindow,
host: State<'_, Host>,
requests: Vec<Confirmation>,
) -> Result<Vec<String>, String> {
main_window(&window)?;
trust_confirm_group(&host, requests)
}
fn trust_confirm_group(host: &Host, requests: Vec<Confirmation>) -> Result<Vec<String>, String> {
if requests.is_empty() || requests.len() > 64 {
return Err("EXTENSION_TRUST_INVALID".into());
}
let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?;
let mut ids = std::collections::BTreeSet::new();
let mut proposals = Vec::new();
for request in &requests {
if !ids.insert(&request.review_id) {
return Err("EXTENSION_TRUST_INVALID".into());
}
let review = reviews
.get(&request.review_id)
.ok_or("EXTENSION_REVIEW_EXPIRED")?;
if review.expires <= Instant::now() {
return Err("EXTENSION_REVIEW_EXPIRED".into());
}
if review.fingerprint != request.fingerprint {
return Err("EXTENSION_TRUST_CONFIRMATION".into());
}
proposals.push((
review.setting.clone(),
review.expected.clone(),
review.fingerprint.clone(),
));
}
let revisions = store(host, |s| s.confirm_trust_group(&proposals))?;
for request in requests {
reviews.remove(&request.review_id);
}
Ok(revisions)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn failed_group_does_not_consume_valid_review_then_success_consumes_all() {
let temp = tempfile::tempdir().unwrap();
let host = Host::default();
*host.extensions.lock().unwrap() = Some(ExtensionStore::open(temp.path()).unwrap());
let mut setting = TrustSetting {
source: "https://catalog.example/".into(),
source_id: "catalog".into(),
namespace: "examples".into(),
key_id: "one".into(),
public_key: ed25519_dalek::SigningKey::from_bytes(&[7; 32])
.verifying_key()
.to_bytes(),
enabled: true,
};
let first = trust_review(&host, setting.clone()).unwrap();
setting.key_id = "two".into();
let second = trust_review(&host, setting).unwrap();
let input = |value: &Value| Confirmation {
review_id: value["review_id"].as_str().unwrap().into(),
fingerprint: value["fingerprint"].as_str().unwrap().into(),
};
let mut wrong = input(&second);
wrong.fingerprint = "wrong".into();
assert!(trust_confirm_group(&host, vec![input(&first), wrong]).is_err());
assert_eq!(host.extension_reviews.0.lock().unwrap().len(), 2);
assert!(store(&host, |s| s.trust_setting(
"https://catalog.example/",
"examples",
"one"
))
.unwrap()
.is_none());
trust_confirm_group(&host, vec![input(&first), input(&second)]).unwrap();
assert!(host.extension_reviews.0.lock().unwrap().is_empty());
}
#[test]
fn review_nonce_expiry_consumption_and_stale_setting_are_enforced() { fn review_nonce_expiry_consumption_and_stale_setting_are_enforced() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let host = Host::default(); let host = Host::default();
+85
View File
@@ -374,6 +374,39 @@ 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)
} }
pub fn confirm_trust_group(
&mut self,
proposals: &[(TrustSetting, Option<String>, String)],
) -> Result<Vec<String>> {
if proposals.is_empty() || proposals.len() > 64 {
return Err(HostError::new("EXTENSION_TRUST_INVALID"));
}
let mut identities = std::collections::BTreeSet::new();
for (setting, _, _) in proposals {
if setting.source != proposals[0].0.source
|| setting.source_id != proposals[0].0.source_id
|| !identities.insert((&setting.namespace, &setting.key_id))
{
return Err(HostError::new("EXTENSION_TRUST_INVALID"));
}
}
self.db
.execute_batch("SAVEPOINT trust_confirmation_group")?;
let result = (|| {
let mut revisions = Vec::new();
for (setting, expected, confirmed) in proposals {
revisions.push(self.confirm_trust(setting, expected.as_deref(), confirmed)?);
}
self.db.execute_batch("RELEASE trust_confirmation_group")?;
Ok(revisions)
})();
if result.is_err() {
self.db.execute_batch(
"ROLLBACK TO trust_confirmation_group; RELEASE trust_confirmation_group",
)?;
}
result
}
fn block_identity( fn block_identity(
source_url: &str, source_url: &str,
release: &Release, release: &Release,
@@ -1112,6 +1145,58 @@ mod tests {
} }
} }
#[test] #[test]
fn grouped_trust_confirmation_rolls_back_earlier_keys_on_later_conflict() {
let temp = tempfile::tempdir().unwrap();
let (_, _, key) = fixture();
let mut store = ExtensionStore::open(temp.path()).unwrap();
let first = TrustSetting {
source: "https://catalog.example/".into(),
source_id: "catalog".into(),
namespace: "examples".into(),
key_id: "first".into(),
public_key: key,
enabled: true,
};
let mut second = first.clone();
second.key_id = "second".into();
store
.confirm_trust(&second, None, &second.fingerprint().unwrap())
.unwrap();
second.enabled = false;
let proposals = vec![
(first.clone(), None, first.fingerprint().unwrap()),
(second.clone(), None, second.fingerprint().unwrap()),
];
assert!(store.confirm_trust_group(&proposals).is_err());
drop(store);
let mut store = ExtensionStore::open(temp.path()).unwrap();
assert!(store
.trust_setting(&first.source, &first.namespace, &first.key_id)
.unwrap()
.is_none());
let old = store
.trust_setting(&second.source, &second.namespace, &second.key_id)
.unwrap()
.unwrap();
assert!(old.enabled);
let mut proposals = proposals;
proposals[1].1 = Some(old.fingerprint().unwrap());
store.confirm_trust_group(&proposals).unwrap();
assert!(store
.trust_setting(&first.source, &first.namespace, &first.key_id)
.unwrap()
.is_some());
assert!(
!store
.trust_setting(&second.source, &second.namespace, &second.key_id)
.unwrap()
.unwrap()
.enabled
);
let duplicate = vec![proposals[0].clone(), proposals[0].clone()];
assert!(store.confirm_trust_group(&duplicate).is_err());
}
#[test]
fn cancelled_stage_reports_durable_commit_race_truthfully() { fn cancelled_stage_reports_durable_commit_race_truthfully() {
let (release, archive, key) = fixture(); let (release, archive, key) = fixture();
for boundary in ["object_stored", "receipt_recorded", "committed"] { for boundary in ["object_stored", "receipt_recorded", "committed"] {
+1
View File
@@ -850,6 +850,7 @@ fn main() {
host_capabilities, host_capabilities,
extension_trust_review, extension_trust_review,
extension_trust_confirm, extension_trust_confirm,
extension_trust_confirm_group,
extension_install_preview, extension_install_preview,
extension_stage, extension_stage,
extension_stage_prepare, extension_stage_prepare,
@@ -10,7 +10,7 @@ it('reviews normalized source and confirms only Host-issued id and fingerprint',
expect(native.invoke).toHaveBeenCalledWith('extension_trust_review', { setting: { source: 'https://catalog.example/', source_id: 'catalog', key_id: 'key', namespace: 'examples', public_key: Array(32).fill(97), enabled: true } }) expect(native.invoke).toHaveBeenCalledWith('extension_trust_review', { setting: { source: 'https://catalog.example/', source_id: 'catalog', key_id: 'key', namespace: 'examples', public_key: Array(32).fill(97), enabled: true } })
expect(native.invoke).toHaveBeenCalledTimes(1) expect(native.invoke).toHaveBeenCalledTimes(1)
await confirmTrust(reviews) await confirmTrust(reviews)
expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm', { request: { review_id: 'review', fingerprint: 'digest' } }) expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm_group', { requests: [{ review_id: 'review', fingerprint: 'digest' }] })
}) })
it('rejects invalid and revoked input before invoking Host', async () => { it('rejects invalid and revoked input before invoking Host', async () => {
await expect(reviewTrust('http://example.com', 'catalog', [key], true)).rejects.toThrow() await expect(reviewTrust('http://example.com', 'catalog', [key], true)).rejects.toThrow()
@@ -24,7 +24,5 @@ export async function reviewTrust(url: string, sourceId: string, keys: Community
} }
export async function confirmTrust(reviews: TrustReview[]): Promise<void> { export async function confirmTrust(reviews: TrustReview[]): Promise<void> {
if (!reviews.length || reviews.length > 64) throw new Error('请重新检查来源后确认') if (!reviews.length || reviews.length > 64) throw new Error('请重新检查来源后确认')
for (const review of reviews) { await invoke('extension_trust_confirm_group', { requests: reviews.map(review => ({ review_id: review.review_id, fingerprint: review.fingerprint })) })
await invoke('extension_trust_confirm', { request: { review_id: review.review_id, fingerprint: review.fingerprint } })
}
} }