PR #186:把远程凭证与 CSP 收进 Electron main 的安全边界

Buffin · main...45dddce · 2026-07-28 · 自包含,读完即弃

4 commits
43 文件
+1486 / −189
57.5% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

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变更地图

CSP + protocol
885 行 · 52.8%
credential bridge
417 行 · 24.9%
daemon + API
224 行 · 13.4%
client transport
149 行 · 8.9%
子系统设计重心(要细读)可放心略过
Electron maincredential-vault.tsrenderer-protocol.tscsp.tsindex.ts 的所有权与启动顺序IPC handler 和 preload wrapper 的重复薄接线
API / daemonorigin-only endpoint、pairing offer canonicalization、app://renderer CORS测试 harness 的窄注入 seam
clientdevice: envelope、connection-owned credential 注入 HTTP/blob2 行的 .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 自带静态安全策略

Electron main
loadFile()
file:// renderer
index.html
loopback-only meta CSP
Chromium
renderer client
runtime token only
local daemon

现在 · main 统一持有凭证与策略

safeStorage vault
daemonId + endpoint + encrypted token
Electron main
Electron main
computed CSP header
app://renderer
preload bridge
raw token by daemonId
device: strategy
范围边界:图中的 device strategy 已可被上层组合给 tRPC、terminal 与 HTTP/blob,但当前 renderer 生产代码还没有创建这条远程连接;P1.6 才负责 enrollment、切换、known-daemon 管理与 expectedDaemonId 绑定。

4数据与状态先行

新增状态的关键不是字段多,而是秘密与非秘密被明确拆开。磁盘 entry 以稳定 daemonId 为 key,endpoint 与密文一起更新;renderer 的列表视图永远看不到 token。

apps/desktop/src/main/credential-vault.ts真实代码(节选)
type StoredCredentialEntry = {
  endpoint: string
  name: string
  encryptedToken: string
}

type CredentialVaultFile = {
  version: 1
  entries: Record<string, StoredCredentialEntry>
}
apps/desktop/src/shared/daemon-credentials.ts真实代码(节选)
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 的编码细节。

packages/client/src/device-credentials.ts真实代码(节选)
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

以前
验证 HTTPS,或 loopback HTTP
保留原 URL 的 path / query / fragment
pairing、WS、HTTP 各自解释这段地址
现在
验证同一 transport security rule
拒绝 userinfo、path、query、fragment
返回唯一 URL.origin,各载体只派生固定路径
packages/api/src/reach.ts真实代码(节选)
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://…/trpcwss://…/terminal 与 HTTP/blob 根地址。config 的坏值仍按 Buffin 的“load 不抛错”约定局部降级为未配置,而不是拖垮 daemon 启动。

排查路标 · 端点不变量
症状从哪下手
pairing link 带了意外 path/querypackages/api/src/reach.tsassertSupportedRemoteDirectBaseUrl,再看 pairing.ts 的 endpoint schema
配置中的 endpoint 被当作未配置apps/daemon/src/storage/config.tssanitizeAdvertisedBaseUrl
凭证看似发到了另一个 originpackages/api/src/reach.tsisTrustedReachUrl 与各 transport URL 派生函数

6旅程 A:一次远端 enrollment 如何入库并取回

全景 · 写入与读取共跨 7 个模块
pairing.claim 结果 window.buffin
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。

apps/desktop/src/main/credential-vault.ts真实代码(节选)
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。

packages/client/src/electron-renderer/index.ts真实代码(节选)
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,而无需再造第四条凭证通道。

packages/client/src/electron-renderer/index.ts真实代码(节选)
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.tsget 与 safeStorage decrypt 包装
renderer 收不到某个 daemonId 的 tokenshared/daemon-credentials.ts → preload → daemon-credential-bridge.ts 四个固定 channel
第一次能认证,重连后 credential 行为异常packages/client/src/device-credentials.ts 的闭包缓存与 onRejected
blob 没带 device credentialelectron-renderer/index.tscreateClient 注入对象是否与 session 共用

7旅程 B:已登记 endpoint 如何变成 Chromium 的唯一 CSP

全景 · packaged 与 development 共享 builder
vault.list()
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。

apps/desktop/src/main/csp.ts真实代码(节选)
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 前声明 standardsecuresupportFetchAPI;ready 后 handler 把 renderer 文件包装成标准 Response,同时附 CSP 与 MIME。

apps/desktop/src/main/renderer-protocol.ts真实代码(节选)
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,
      },
    },
  ])
}
apps/desktop/src/main/renderer-protocol.ts真实代码(节选)
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 原样保留。两种环境因此共享安全规则,只在交付通道上不同。

apps/desktop/src/main/dev-csp.ts真实代码(节选)
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 白屏或静态资源 404renderer-protocol.ts 的 scheme 注册、host 检查、resolveRendererFile 与 MIME 表
已登记 HTTPS 可 fetch,但 WSS 被拦csp.tstoSocketOrigin 与最终 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 重复执行同一 assertionPR #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心智模型补丁

daemon 地址只要协议安全就能作为 remote-direct base URL地址必须是 canonical origin;carrier path 一律由 transport 自己派生
renderer HTML 自己声明 CSPElectron main 是唯一 CSP owner,HTML 不得再携带 meta policy
packaged renderer 是 file:// 页面,CORS origin 可能是 null正式入口是 app://renderer/index.html,daemon 明确允许 app://renderer
desktop 只有本机 per-boot daemon tokenmain 还拥有以 daemonId 分区的长期 remote device-token vault
HTTP client 总是自己创建本地 credentialHTTP/blob 可接受 connection-owned strategy,与 tRPC/terminal 共享 credential 生命周期
看到远端 vault 与 IPC 就可假设远程桌面流程已完成这些是 P1.6 的底层入口;当前没有 renderer 生产调用点创建远端 device strategy

10新词表

身份与凭证
CredentialVaultElectron main 独占的远端 daemon 长期凭证文件,磁盘只放 safeStorage 密文。
KnownDaemonrenderer 可见的非秘密 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:// 环境。
合并前不要误判:本次验证证明 PR 自身检查、测试与构建通过;它没有证明 P1 exit criteria 中的两台真实机器 Tailscale enrollment round-trip,也没有证明远程 daemon UI 已可用。

12验收提示与覆盖声明

覆盖声明:三个子系统精读任务分别全量阅读 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 中通过。