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
+1 -1
View File
@@ -1,7 +1,7 @@
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
export type PackageKind = 'theme' | 'skill' | 'plugin' | 'mcp' | 'persona' | 'template' | 'model'
export interface CommunityKey { key_id: string; namespace: string; public_key: string; revoked: boolean }
export interface CommunitySource { id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
export interface CommunitySource { source_id?: string; id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
export interface CommunityRelease {
schema_version: 1; namespace: string; package_id: string; type: PackageKind; version: string
name: string; author_id: string; license: string; description: string; sha256: string; size: number
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
const service = vi.hoisted(() => ({ discover: vi.fn(), review: vi.fn(), confirm: vi.fn(), save: vi.fn() }))
vi.mock('@/services/platform/desktop', () => ({ isDesktop: () => true }))
vi.mock('@/services/extensionTrustService', () => ({ reviewTrust: service.review, confirmTrust: service.confirm }))
vi.mock('@/services/communityService', () => ({ loadSources: () => [], saveSources: service.save, discoverSource: service.discover, cachedCatalog: vi.fn(), fetchCatalog: vi.fn(), installRelease: vi.fn() }))
import CommunityView from './CommunityView.vue'
beforeEach(() => { vi.clearAllMocks(); localStorage.clear() })
it('requires explicit confirmation and does not save when Host rejects it', async () => {
service.discover.mockResolvedValue({ source_id: 'catalog', keys: [{ key_id: 'key', namespace: 'examples', public_key: 'public', revoked: false }] })
service.review.mockResolvedValue([{ review_id: 'review', fingerprint: 'visible-digest', previous: null, proposed: { namespace: 'examples', key_id: 'key' } }])
const wrapper = mount(CommunityView, { global: { stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await wrapper.get('input').setValue('https://catalog.example')
await wrapper.findAll('button').find(b => b.text() === '检查来源与公钥')!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('visible-digest')
expect(service.confirm).not.toHaveBeenCalled()
expect(service.save).not.toHaveBeenCalled()
service.confirm.mockRejectedValueOnce(new Error('确认已过期'))
await wrapper.findAll('button').find(b => b.text() === '确认来源设置')!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('确认已过期')
expect(service.save).not.toHaveBeenCalled()
wrapper.unmount()
})
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } from '@/contracts/community'
import { cachedCatalog, discoverKeys, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
import { cachedCatalog, discoverSource, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
import { isDesktop } from '@/services/platform/desktop'
import { reviewTrust, confirmTrust, type TrustReview } from '@/services/extensionTrustService'
import AppDialog from '@/components/common/AppDialog.vue'
const kinds: { id: PackageKind | ''; label: string }[] = [
@@ -13,6 +15,7 @@ const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ??
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
const candidateKeys = ref<CommunityKey[]>([]), candidateUrl = ref('')
const candidateSourceId = ref(''), candidateEnabled = ref(true), trustReviews = ref<TrustReview[]>([])
const busy = ref(false), error = ref(''), notice = ref(''), offline = ref(false)
let controller: AbortController | undefined
let version = 0
@@ -38,14 +41,22 @@ async function run(action: (signal: AbortSignal, current: () => boolean) => Prom
}
function inspectSource() {
const snapshot = url.value.trim()
void run(async (signal, current) => { const keys = await discoverKeys(snapshot, signal); if (current()) { candidateKeys.value = keys; candidateUrl.value = snapshot } })
void run(async (signal, current) => {
const discovered = await discoverSource(snapshot, signal)
const reviews = isDesktop() ? await reviewTrust(snapshot, discovered.source_id, discovered.keys, true) : []
if (current()) { candidateKeys.value = discovered.keys; candidateSourceId.value = discovered.source_id; candidateEnabled.value = true; trustReviews.value = reviews; candidateUrl.value = snapshot }
})
}
function trustSource() {
const existing = sources.value.find(item => item.url === candidateUrl.value)
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), url: candidateUrl.value, enabled: true, keys: candidateKeys.value, fetchedAt: new Date().toISOString() }
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
saveSources(sources.value); selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []
search()
const snapshot = { url: candidateUrl.value, keys: candidateKeys.value, sourceId: candidateSourceId.value, enabled: candidateEnabled.value, reviews: trustReviews.value }
void run(async (_signal, current) => {
if (isDesktop()) await confirmTrust(snapshot.reviews)
const existing = sources.value.find(item => item.url === snapshot.url)
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), source_id: snapshot.sourceId, url: snapshot.url, enabled: snapshot.enabled, keys: snapshot.keys, fetchedAt: new Date().toISOString() }
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
saveSources(sources.value)
if (current()) { selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []; trustReviews.value = []; notice.value = snapshot.enabled ? '来源公钥已固定。可以搜索目录。' : '来源已停用。'; items.value = [] }
})
}
function search() {
void run(async (signal, current) => {
@@ -69,8 +80,13 @@ function install() {
})
}
function toggleSource() {
const selected = source(); selected.enabled = !selected.enabled; saveSources(sources.value)
items.value = []; cancel()
const selected = source()
if (!isDesktop()) { selected.enabled = !selected.enabled; saveSources(sources.value); items.value = []; cancel(); return }
if (!selected.source_id) { error.value = '请先重新检查此来源并确认公钥,将旧来源设置迁入桌面信任库。'; return }
void run(async (_signal, current) => {
const reviews = await reviewTrust(selected.url, selected.source_id!, selected.keys, !selected.enabled)
if (current()) { candidateKeys.value = selected.keys; candidateSourceId.value = selected.source_id!; candidateEnabled.value = !selected.enabled; trustReviews.value = reviews; candidateUrl.value = selected.url }
})
}
</script>
@@ -109,10 +125,17 @@ function toggleSource() {
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设MCP 或模型运行配置</p>
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
</section>
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''">
<p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对确认后固定这些公钥密钥改变时不会自动信任</p>
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''; trustReviews = []">
<p v-if="error" role="alert">{{ error }}</p><p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对确认后固定这些公钥密钥改变时不会自动信任</p>
<p>来源标识{{ candidateSourceId }} · {{ candidateEnabled ? '启用' : '停用' }}</p>
<pre>{{ JSON.stringify(candidateKeys, null, 2) }}</pre>
<button class="btn btn-primary" :disabled="!candidateKeys.length" @click="trustSource">确认并固定公钥</button>
<div v-for="review in trustReviews" :key="review.review_id">
<p>{{ review.proposed.namespace }} / {{ review.proposed.key_id }}{{ review.previous ? '更新已有信任设置' : '首次确认' }}</p>
<details v-if="review.previous"><summary>原有公钥与状态</summary><pre>{{ JSON.stringify(review.previous, null, 2) }}</pre></details>
<p>确认摘要{{ review.fingerprint }}</p>
</div>
<p v-if="trustReviews.length">确认在两分钟内有效过期或设置已改变时请关闭对话框并重新检查来源</p>
<button class="btn btn-primary" :disabled="busy || !candidateKeys.length" @click="trustSource">确认来源设置</button>
</AppDialog>
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
<template v-if="detail">
@@ -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 } })
}
}