feat(community): 通过 Host 审阅确认桌面来源信任
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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('来源公钥数量必须为 1–64')
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user