feat(community): 通过 Host 审阅确认桌面来源信任
This commit is contained in:
@@ -269,3 +269,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 安装预览由 Host 注入应用版本、OS 与架构,renderer 只提交根包、当前 Vault 和配置;后台阻塞任务中保持 Vault 绑定稳定并调用完整预览。尚未开放实际安装执行命令。
|
- 安装预览由 Host 注入应用版本、OS 与架构,renderer 只提交根包、当前 Vault 和配置;后台阻塞任务中保持 Vault 绑定稳定并调用完整预览。尚未开放实际安装执行命令。
|
||||||
- 桌面二进制 5 项测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-commands-tests.log`。新增确认凭据生命周期与并发冲突测试;ACL 文件及生成权限项已更新。
|
- 桌面二进制 5 项测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-commands-tests.log`。新增确认凭据生命周期与并发冲突测试;ACL 文件及生成权限项已更新。
|
||||||
- 前端确认对话框、桌面包下载/暂存入口、安装执行编排、沙箱及真实 UI 验收尚待完成;extensions capability 仍关闭。本轮不宣称生产化或 D-04 完成。
|
- 前端确认对话框、桌面包下载/暂存入口、安装执行编排、沙箱及真实 UI 验收尚待完成;extensions capability 仍关闭。本轮不宣称生产化或 D-04 完成。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:社区来源确认界面接入 Host
|
||||||
|
|
||||||
|
- 桌面社区检查来源后请求 Host 复核,对话框展示 source_id、候选公钥、原有设置和确认摘要;只有点击确认才调用 Host 确认命令,成功后更新本地目录设置。启用/停用也经同一确认流程更新 Host,旧本地来源不自动迁入信任库。
|
||||||
|
- 新增 extensionTrustService,规范 HTTPS 来源、公钥解码和数量限制;确认只传 Host 签发的 review_id 与摘要。两分钟过期或并发冲突提示重新检查;多键确认是逐键调用,部分失败可能已有部分键完成确认,界面不声称整组成功,需重新检查后处理。
|
||||||
|
- 来源发现保留服务端 source_id,Web 开发保留本地来源流程。对话框中的错误可见,忙碌时禁止重复确认。
|
||||||
|
- 前端全量 99 文件 / 521 项通过,两套 TypeScript 项目检查通过;日志 `.build/extension-trust-ui-tests.log`。新增服务参数/非法输入测试与 Vue 组件确认前无写入、Host 拒绝后不保存测试。测试使用 Host mock,不等同于真实桌面 UI 端到端验收。
|
||||||
|
- 桌面包下载暂存、安装确认 UI 与执行编排、沙箱、真实双端及部署验收仍未完成;整体生产化目标继续进行。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
|
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
|
||||||
export type PackageKind = 'theme' | 'skill' | 'plugin' | 'mcp' | 'persona' | 'template' | 'model'
|
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 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 {
|
export interface CommunityRelease {
|
||||||
schema_version: 1; namespace: string; package_id: string; type: PackageKind; version: string
|
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
|
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">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } from '@/contracts/community'
|
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'
|
import AppDialog from '@/components/common/AppDialog.vue'
|
||||||
|
|
||||||
const kinds: { id: PackageKind | ''; label: string }[] = [
|
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 url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
|
||||||
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
|
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
|
||||||
const candidateKeys = ref<CommunityKey[]>([]), candidateUrl = ref('')
|
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)
|
const busy = ref(false), error = ref(''), notice = ref(''), offline = ref(false)
|
||||||
let controller: AbortController | undefined
|
let controller: AbortController | undefined
|
||||||
let version = 0
|
let version = 0
|
||||||
@@ -38,14 +41,22 @@ async function run(action: (signal: AbortSignal, current: () => boolean) => Prom
|
|||||||
}
|
}
|
||||||
function inspectSource() {
|
function inspectSource() {
|
||||||
const snapshot = url.value.trim()
|
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() {
|
function trustSource() {
|
||||||
const existing = sources.value.find(item => item.url === candidateUrl.value)
|
const snapshot = { url: candidateUrl.value, keys: candidateKeys.value, sourceId: candidateSourceId.value, enabled: candidateEnabled.value, reviews: trustReviews.value }
|
||||||
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), url: candidateUrl.value, enabled: true, keys: candidateKeys.value, fetchedAt: new Date().toISOString() }
|
void run(async (_signal, current) => {
|
||||||
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
|
if (isDesktop()) await confirmTrust(snapshot.reviews)
|
||||||
saveSources(sources.value); selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []
|
const existing = sources.value.find(item => item.url === snapshot.url)
|
||||||
search()
|
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() {
|
function search() {
|
||||||
void run(async (signal, current) => {
|
void run(async (signal, current) => {
|
||||||
@@ -69,8 +80,13 @@ function install() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
function toggleSource() {
|
function toggleSource() {
|
||||||
const selected = source(); selected.enabled = !selected.enabled; saveSources(sources.value)
|
const selected = source()
|
||||||
items.value = []; cancel()
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -109,10 +125,17 @@ function toggleSource() {
|
|||||||
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
<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>
|
<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>
|
</section>
|
||||||
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''">
|
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''; trustReviews = []">
|
||||||
<p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对。确认后固定这些公钥;密钥改变时不会自动信任。</p>
|
<p v-if="error" role="alert">{{ error }}</p><p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对。确认后固定这些公钥;密钥改变时不会自动信任。</p>
|
||||||
|
<p>来源标识:{{ candidateSourceId }} · {{ candidateEnabled ? '启用' : '停用' }}</p>
|
||||||
<pre>{{ JSON.stringify(candidateKeys, null, 2) }}</pre>
|
<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>
|
||||||
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
|
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('社区 Python / TypeScript 签名契约', () => {
|
|||||||
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
|
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
|
||||||
})
|
})
|
||||||
it('不携带凭据、拒绝重定向并支持取消', async () => {
|
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)
|
vi.stubGlobal('fetch', fetch)
|
||||||
await discoverKeys('https://example.org')
|
await discoverKeys('https://example.org')
|
||||||
expect(fetch.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer' })
|
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) }
|
} 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 source: CommunitySource = { id: 'candidate', url, enabled: true, keys: [] }
|
||||||
const value = JSON.parse(new TextDecoder().decode(await download(source, '/catalog/v1/sources', 1024 * 1024, signal)))
|
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('不支持的社区来源协议')
|
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 value.keys
|
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> {
|
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