feat(community): 通过 Host 审阅确认桌面来源信任

This commit is contained in:
2026-09-08 21:55:33 +08:00
parent 270e77a9c8
commit 86f59d463e
8 changed files with 129 additions and 17 deletions
@@ -21,7 +21,7 @@ describe('社区 Python / TypeScript 签名契约', () => {
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
})
it('不携带凭据、拒绝重定向并支持取消', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, keys: [] })))
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, source_id: "fixture", keys: [] })))
vi.stubGlobal('fetch', fetch)
await discoverKeys('https://example.org')
expect(fetch.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer' })
+6 -3
View File
@@ -47,11 +47,14 @@ async function download(source: CommunitySource, path: string, maxSize: number,
} finally { clearTimeout(timeout); signal?.removeEventListener('abort', abort) }
}
export async function discoverKeys(url: string, signal?: AbortSignal): Promise<CommunityKey[]> {
export async function discoverSource(url: string, signal?: AbortSignal): Promise<{ source_id: string; keys: CommunityKey[] }> {
const source: CommunitySource = { id: 'candidate', url, enabled: true, keys: [] }
const value = JSON.parse(new TextDecoder().decode(await download(source, '/catalog/v1/sources', 1024 * 1024, signal)))
if (value.schema_version !== 1 || !Array.isArray(value.keys)) throw new Error('不支持的社区来源协议')
return value.keys
if (value.schema_version !== 1 || typeof value.source_id !== 'string' || !value.source_id || !Array.isArray(value.keys) || value.keys.length > 64) throw new Error('不支持的社区来源协议')
return { source_id: value.source_id, keys: value.keys }
}
export async function discoverKeys(url: string, signal?: AbortSignal): Promise<CommunityKey[]> {
return (await discoverSource(url, signal)).keys
}
export async function fetchCatalog(source: CommunitySource, q = '', kind = '', signal?: AbortSignal): Promise<CommunityCatalog> {
@@ -0,0 +1,21 @@
import { beforeEach, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => ({ invoke: vi.fn() }))
vi.mock('@tauri-apps/api/core', () => ({ invoke: native.invoke }))
import { confirmTrust, reviewTrust } from './extensionTrustService'
beforeEach(() => native.invoke.mockReset())
const key = { key_id: 'key', namespace: 'examples', public_key: btoa('a'.repeat(32)), revoked: false }
it('reviews normalized source and confirms only Host-issued id and fingerprint', async () => {
native.invoke.mockResolvedValueOnce({ review_id: 'review', fingerprint: 'digest', previous: null })
const reviews = await reviewTrust('https://catalog.example', 'catalog', [key], 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)
await confirmTrust(reviews)
expect(native.invoke).toHaveBeenLastCalledWith('extension_trust_confirm', { request: { review_id: 'review', fingerprint: 'digest' } })
})
it('rejects invalid and revoked input before invoking Host', async () => {
await expect(reviewTrust('http://example.com', 'catalog', [key], true)).rejects.toThrow()
await expect(reviewTrust('https://example.com', 'catalog', [{ ...key, revoked: true }], true)).rejects.toThrow()
await expect(reviewTrust('https://example.com', 'catalog', [{ ...key, public_key: 'bad' }], true)).rejects.toThrow()
await expect(confirmTrust([])).rejects.toThrow()
expect(native.invoke).not.toHaveBeenCalled()
})
@@ -0,0 +1,30 @@
import { invoke } from '@tauri-apps/api/core'
import type { CommunityKey } from '@/contracts/community'
export interface TrustSetting {
source: string; source_id: string; namespace: string; key_id: string
public_key: number[]; enabled: boolean
}
export interface TrustReview {
review_id: string; fingerprint: string; previous: TrustSetting | null; proposed: TrustSetting
}
export async function reviewTrust(url: string, sourceId: string, keys: CommunityKey[], enabled: boolean): Promise<TrustReview[]> {
const source = new URL(url)
if (source.protocol !== 'https:' || source.username || source.password || source.search || source.hash) throw new Error('桌面信任来源必须使用 HTTPS')
source.pathname = `${source.pathname.replace(/\/+$/, '')}/`
if (!keys.length || keys.length > 64) throw new Error('来源公钥数量必须为 164')
const settings = keys.map(key => {
const publicKey = Array.from(atob(key.public_key), c => c.charCodeAt(0))
if (publicKey.length !== 32 || (enabled && key.revoked)) throw new Error('公钥无效或已撤销')
return { source: source.toString(), source_id: sourceId, namespace: key.namespace, key_id: key.key_id, public_key: publicKey, enabled }
})
const reviews: TrustReview[] = []
for (const setting of settings) reviews.push(await invoke<TrustReview>('extension_trust_review', { setting }))
return reviews
}
export async function confirmTrust(reviews: TrustReview[]): Promise<void> {
if (!reviews.length || reviews.length > 64) throw new Error('请重新检查来源后确认')
for (const review of reviews) {
await invoke('extension_trust_confirm', { request: { review_id: review.review_id, fingerprint: review.fingerprint } })
}
}