fix(community): 原子确认多密钥来源信任
This commit is contained in:
@@ -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`。验证后续键冲突后重开仍无部分更新,以及失败保留有效凭据、成功全部消耗。
|
||||||
|
- 真实桌面确认链路端到端、安装执行、沙箱及总体生产化验收仍未完成。
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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 } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user