PR #186:把远程凭证与 CSP 收进 Electron main 的安全边界
Buffin · main...45dddce · 2026-07-28 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 完成的是远程直连的安全底座,不是远程 daemon 的完整桌面体验。它先把 daemon 对外地址收窄成唯一 canonical origin,再把长期 device token 放进 Electron main 持有的 safeStorage vault,最后让 main 根据已登记 endpoint 生成唯一 CSP,并通过 app://renderer 或开发服务器响应头交付。
因此,凭证、transport trust 与 CSP 允许列表开始共享同一份 endpoint 事实源。与此同时,createRendererDeviceCredentialStrategy、四个新 IPC 方法与 known-daemon 列表目前仍是底层能力:P1.6 的添加、切换、管理远端 daemon UI 尚未落地。
2变更地图
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| Electron main | credential-vault.ts、renderer-protocol.ts、csp.ts、index.ts 的所有权与启动顺序 | IPC handler 和 preload wrapper 的重复薄接线 |
| API / daemon | origin-only endpoint、pairing offer canonicalization、app://renderer CORS | 测试 harness 的窄注入 seam |
| client | device: envelope、connection-owned credential 注入 HTTP/blob | 2 行的 .js source-loader shim 与导出胶水 |
| tests / scripts | 真实 Electron CSP + CORS 探针、生产 CSP builder 的仓库 gate | 既有 window API mock 补字段 |
测试代码占 963 / 1675 变更行。这里的测试量并非装饰:CSP 的关键证明发生在真实 Chromium/Electron 中,pairing 过期和 issuance failure 则穿过真实 tRPC WebSocket 服务栈。
3架构一图流
以前 · 本地 renderer 自带静态安全策略
现在 · main 统一持有凭证与策略
expectedDaemonId 绑定。4数据与状态先行
新增状态的关键不是字段多,而是秘密与非秘密被明确拆开。磁盘 entry 以稳定 daemonId 为 key,endpoint 与密文一起更新;renderer 的列表视图永远看不到 token。
type StoredCredentialEntry = {
endpoint: string
name: string
encryptedToken: string
}
type CredentialVaultFile = {
version: 1
entries: Record<string, StoredCredentialEntry>
}
export type KnownDaemon = {
readonly daemonId: string
readonly endpoint: string
readonly name: string
}
export type SetDaemonCredentialInput = KnownDaemon & {
readonly token: string
}
client 侧只要求平台提供裸 token;wire 前缀、缓存和失效逻辑属于通用 strategy。这样 Electron vault 不必知道 tRPC、terminal 或 blob 的编码细节。
export function createDeviceCredentialStrategy(
store: DeviceCredentialStore,
): CredentialStrategy {
let cachedCredential: string | undefined
return {
async getCredential() {
if (cachedCredential !== undefined) return cachedCredential
const token = await store.getToken()
if (!token) throw new Error('Device credential is unavailable.')
cachedCredential = `device:${token}`
return cachedCredential
},
async onRejected() {
cachedCredential = undefined
},
}
}
5底座:一个 canonical origin 派生所有载体
本分支第一笔提交修补了 PR #180 留下的 endpoint 宽松度:以前只要协议安全,URL 即使带 path、query 或 fragment 也能进入 config / offer;现在 remote-direct base URL 必须只含 scheme、host 与 port,并返回规范化后的 origin。
if (
url.username !== '' ||
url.password !== '' ||
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== ''
) {
throw new Error(
'Remote-direct daemon endpoints must contain only an origin: ' +
'omit credentials, paths, query parameters, and fragments.',
)
}
return new URL(url.origin)
于是同一个 https://daemon.example.com 可以稳定派生 pairing link、wss://…/trpc、wss://…/terminal 与 HTTP/blob 根地址。config 的坏值仍按 Buffin 的“load 不抛错”约定局部降级为未配置,而不是拖垮 daemon 启动。
排查路标 · 端点不变量
| 症状 | 从哪下手 |
|---|---|
| pairing link 带了意外 path/query | packages/api/src/reach.ts 的 assertSupportedRemoteDirectBaseUrl,再看 pairing.ts 的 endpoint schema |
| 配置中的 endpoint 被当作未配置 | apps/daemon/src/storage/config.ts 的 sanitizeAdvertisedBaseUrl |
| 凭证看似发到了另一个 origin | packages/api/src/reach.ts 的 isTrustedReachUrl 与各 transport URL 派生函数 |
6旅程 A:一次远端 enrollment 如何入库并取回
preload/index.ts→ IPC bridge
daemon-credential-bridge.ts→ safeStorage vault
credential-vault.ts→ device strategy
device-credentials.ts
A.1写入时:不允许明文后备
safeStorage.isEncryptionAvailable() 是写入硬门。Linux 没有 keyring / secret portal 时,set 直接给出可操作错误;没有 plaintext fallback。endpoint 在这里再次 canonicalize,token trim 后才加密,临时文件以 0600 写完再 rename。
set(input) {
if (!storage.isEncryptionAvailable()) {
throw new Error(
'Secure credential storage is unavailable. Configure a system ' +
'keyring or secret portal before pairing a remote daemon.',
)
}
const daemonId = validateDaemonId(input.daemonId)
const endpoint = assertSupportedRemoteDirectBaseUrl(input.endpoint).origin
const token = validateRequiredString(input.token, 'Device credential')
const vault = readVault(path)
vault.entries[daemonId] = {
endpoint,
name: validateRequiredString(input.name, 'Daemon name'),
encryptedToken: storage.encryptString(token).toString('base64'),
}
writeVault(path, vault)
}
读取的失败语义与写入不同:list() 不解密,仍能列元数据;get() 直接尝试 decrypt,keyring 锁定、密文损坏或密钥环境变化统一表现为“stored credential could not be decrypted”。整个 JSON shape 非法则 fail closed,不会静默清空。
A.2取用时:secret 留在 main,renderer 只按 daemonId 请求
preload 没有暴露通用 ipcRenderer.invoke,而是四个固定方法。renderer factory 只知道 daemonId;main 解密返回裸 token,通用 strategy 再包装成 device: wire credential。
export function createRendererDeviceCredentialStrategy(
daemonId: string,
): CredentialStrategy {
return createDeviceCredentialStrategy({
async getToken() {
return currentDeviceCredentialBridge()?.(daemonId) ?? null
},
})
}
同一 strategy 对象现在也能注入 renderer HTTP client。tRPC 和 terminal 本来就读取 connection-owned credential,所以 P1.6 只需在组合根创建一次 strategy,并把它交给 session 与 HTTP/blob,而无需再造第四条凭证通道。
export function createClient(connection: RendererConnection = {}): DaemonHttpClient {
const env = connection.env ?? {}
const baseUrl = connection.baseUrl ?? resolveDaemonBaseUrl(env)
const credential =
connection.credential ?? createRendererCredentialStrategy(env)
return createDaemonHttpClient(
createHttpTransport({
baseUrl,
fetch: globalThis.fetch,
requestSource: 'desktop',
credential: createCredentialProvider(credential),
...(connection.reach ? { reach: connection.reach } : {}),
}),
)
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 远端 daemon 列表存在,但 token 取不出来 | credential-vault.ts 的 get 与 safeStorage decrypt 包装 |
| renderer 收不到某个 daemonId 的 token | shared/daemon-credentials.ts → preload → daemon-credential-bridge.ts 四个固定 channel |
| 第一次能认证,重连后 credential 行为异常 | packages/client/src/device-credentials.ts 的闭包缓存与 onRejected |
| blob 没带 device credential | electron-renderer/index.ts 的 createClient 注入对象是否与 session 共用 |
7旅程 B:已登记 endpoint 如何变成 Chromium 的唯一 CSP
endpoint metadata→ buildRendererCsp()
csp.ts→ response header
app protocol / webRequest→ Chromium
HTTPS + WSS allowlist
这里必须只有一条有效 policy:HTML meta 与响应头不会相互覆盖,而会同时生效并取交集。旧 meta 只允许 loopback,所以它若留下,运行时新增 remote origin 仍会被旧 policy 拦住。本 PR 因而删除 meta,并把 policy 所有权上移到 Electron main。
export function buildRendererCsp(remoteEndpoints: Iterable<string>): string {
const connectSources = new Set(["'self'", ...LOOPBACK_CONNECT_SOURCES])
for (const endpoint of remoteEndpoints) {
const origin = canonicalRemoteOrigin(endpoint)
if (!origin) continue
connectSources.add(origin)
connectSources.add(toSocketOrigin(origin))
}
return [
"default-src 'self'",
`connect-src ${[...connectSources].join(' ')}`,
"script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
].join('; ')
}
B.1打包环境:从 file:// 切到 app://renderer
file:// 资源没有一个可由应用 handler 动态附上 CSP 的 response 管线。新的 privileged scheme 在 Electron ready 前声明 standard、secure 与 supportFetchAPI;ready 后 handler 把 renderer 文件包装成标准 Response,同时附 CSP 与 MIME。
export const PACKAGED_RENDERER_ENTRY_URL =
'app://renderer/index.html'
export function registerRendererScheme(
registrar: RendererSchemeRegistrar,
): void {
registrar.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
},
},
])
}
return new Response(new Uint8Array(bytes), {
headers: {
'Content-Security-Policy': csp,
'Content-Type':
CONTENT_TYPES[extname(filePath).toLowerCase()] ??
'application/octet-stream',
},
})
协议 resolver 同时固定 host 为 renderer,拒绝反斜杠、NUL、非法 escape 与 root escape。新 origin 还跨越到 daemon 的 CORS 边界,因此默认 allowlist 精确增加 app://renderer;旧 file:// / null 兼容项没有在本 PR 删除。
B.2开发环境:不换入口,只替换响应头
开发服务器继续使用 HTTP origin。main 在 default session 上注册 onHeadersReceived,先删除大小写任意的旧 CSP header,再加入实时计算的新 policy,其他 response headers 原样保留。两种环境因此共享安全规则,只在交付通道上不同。
for (const name of Object.keys(responseHeaders)) {
if (name.toLowerCase() === RENDERER_CSP_HEADER.toLowerCase()) {
delete responseHeaders[name]
}
}
responseHeaders[RENDERER_CSP_HEADER] = [getCsp()]
callback({ responseHeaders })
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| packaged renderer 白屏或静态资源 404 | renderer-protocol.ts 的 scheme 注册、host 检查、resolveRendererFile 与 MIME 表 |
| 已登记 HTTPS 可 fetch,但 WSS 被拦 | csp.ts 的 toSocketOrigin 与最终 connect-src |
| 开发环境看见两条 CSP 或远端仍被拦 | dev-csp.ts 的 header 去重;同时确认 index.html 没重新出现 meta |
| packaged renderer 请求 daemon 被 CORS 拒绝 | apps/daemon/src/app.ts 的 default CORS origins,应包含 app://renderer |
8计划 vs 实现的偏差
| 计划 | 实际实现 | 认知差异 |
|---|---|---|
advertisedBaseUrl 是四个消费者的单一事实源 | 补充收紧为 origin-only,并在 config、pairing schema、trust gate 重复执行同一 assertion | PR #180 的初版只限制协议,仍允许 path/query;本 PR 的首个 fix 提交把“单一事实源”补成真正可组合的不变量 |
P1.4 vault 由 safeStorage 加密,以 daemonId 为 key,无明文 fallback | 按计划实现;另选择“每次操作读盘、整体 shape 非法即失败”的 fail-closed 文件语义 | 计划没有展开锁定/损坏细节;实际代码把 list/delete 与 decrypt 分离,元数据可读不代表 secret 可解密 |
P1.5 packaged renderer 迁到 app://renderer,HTML 只剩一条 main-owned policy | 按计划实现,且 dev server 也用同一个 builder;仓库 gate 改为执行真实 TypeScript builder | 检查不再依赖复制的 HTML 字符串,安全规则的真相源与生产代码一致 |
| P1.1 判断既有 packaged origin 无需新增 CORS 配置 | app://renderer 上线后 daemon 默认 allowlist 增加这一精确 origin | 旧结论针对 file:///null;renderer origin 改变后出现了新的同分支 CORS 跟进 |
| vault 最终服务 renderer credential、CSP、known-daemon UI 三个消费者 | 本 PR 完成 credential factory、IPC 与 CSP 消费;known-daemon enrollment/switching UI 未实现 | 这是 P1.4/P1.5 与 P1.6 的明确切口,不应把 API 存在理解成用户旅程已接通 |
9心智模型补丁
10新词表
| 身份与凭证 | |
|---|---|
CredentialVault | Electron main 独占的远端 daemon 长期凭证文件,磁盘只放 safeStorage 密文。 |
KnownDaemon | renderer 可见的非秘密 enrollment 元数据:daemonId、endpoint、name。 |
device: envelope | 在 pairing claim 返回的裸 token 前加 discriminator 后形成的 wire credential。 |
| connection-owned credential | 由连接组合根创建、供 tRPC、terminal、HTTP/blob 共同使用的 strategy。 |
| Renderer 安全边界 | |
app://renderer | 打包 renderer 的稳定、安全、Fetch-capable 自定义 origin。 |
| main-owned response policy | 由 Electron main 计算并通过响应头交付的唯一 CSP。 |
| WSS twin | 每个登记 HTTPS endpoint 对应的同 host/port WebSocket CSP source。 |
| CSP intersection | 同一 document 上多条 policy 同时约束;宽松 header 不能覆盖更窄 meta。 |
11测试与风险地图
有兜底的
- config / pairing / reach 的 canonical origin 与拒绝语义。
- device strategy 前缀、缓存、重复 invalidation 与重读。
- vault 明文不落盘、safeStorage unavailable 拒绝、损坏 JSON 失败。
- IPC / preload 四个固定 channel。
- app protocol 的 host、path escape、MIME 与每请求重算 CSP。
- 真实 Electron 中 configured origin 可达、unconfigured origin 在 CSP 层被拦、app://renderer CORS 预检成功。
- pairing offer 过期与 issuance failure 后 offer burn 的真实 WS 行为。
- 仓库 gate 执行生产 builder,并禁止 HTML 恢复第二份 meta CSP。
薄冰 / 尚未声称完成
- 🟠P1.6 enrollment、切换、设备管理 UI 未开始;新 factory 没有生产 renderer 调用点。
- 🟠没有一条测试让同一个真实 strategy 一次性穿过 tRPC、terminal 与 HTTP/blob。
- 🟡真实 Electron probe 覆盖 HTTPS fetch 与 CORS,没有发起真实 WSS。
- 🟡开发 CSP hook 是 port/mock 测试,没有与真实 Vite dev server 联跑。
- 🟡vault shape、排序、文件 mode、decrypt failure 等若干分支由代码约束,缺专门断言。
- ⚪真实 WS fixture 监听 loopback ws://,不是证书/Tailscale wss:// 环境。
12验收提示与覆盖声明
- 看到
listKnownDaemons/getDaemonCredential/set/delete没有 renderer 调用,不是这组底层接线漏挂;用户流程属于尚未开始的 P1.6。 file://与null仍留在 daemon CORS allowlist,是旧 packaged origin 的兼容项;新正式路径是app://renderer。- protocol 给 HTML 与静态资产都附 CSP;决定 document policy 的关键响应仍是 entry document。
- vault 更新后 builder 会在下次请求重新计算,但已加载 document 的有效 CSP 要到 reload/navigation 才完整更新。
- 工作区 6 份未跟踪中英双语 relay 文档仅用于理解计划与实现边界,不属于
main...HEAD,也未被修改。
覆盖声明:三个子系统精读任务分别全量阅读 daemon/API/client、credential vault/bridge、CSP/protocol/scripts 的全部变更文件;主 agent 另外回读了报告引用的每个 after 文件与对应 base 侧入口。总计覆盖 PR #186 的 43 个文件与完整三点 diff,没有抽样。当前任务实际运行 BUFFIN_REQUIRE_LOOPBACK=1 bun run verify:402 个测试文件通过、2 个按预期跳过;3991 个测试通过、3 个跳过;typecheck、lint、daemon/CLI/desktop build 全部通过。git diff --check main...HEAD 通过。
环境中没有可用浏览器二进制,因此本地报告展示采用 HTML parser、模板 CSS 精确比对、锚点校验与本地 HTTP 响应验证作为 fallback;真实 Electron/Chromium CSP 测试属于仓库测试套件并在本次 verify 中通过。