PR #180 解读:pairing 与 device access controls

feat/relay-p1-123 · aa07224 · 对比 merge-base a072104 · 56 个文件

类型 功能 + 安全边界 规模 3,389 行变更 测试 1,783 行 · 52.6% 范围 P1.1–P1.3
写法说明:灰色注释来自源码语义,青色注释是本报告的解读。每条旅程末尾都有“排查路标”,用于把可观察症状映射回文件和函数。本报告只帮助理解,不做 code review。

1TL;DR

这个分支把 daemon 的认证模型从“只有本机进程 token”扩成三类主体:daemon-runtime、短期 pairing、持久 device

本机 CLI 可以生成十分钟、单次使用的配对 offer;远端设备用 offer 连接,只能做 daemon.hellopairing.claim,随后换到一枚可撤销的设备 token。

设备 token 的明文只返回一次,数据库只存 sha256;撤销先提交到 SQLite,再关闭该设备已认证的 tRPC 和 terminal WebSocket,后续 blob 请求也会被拒绝。

同时 daemon 的监听地址与对外地址被拆开:bindHost 决定监听,advertisedBaseUrl 决定 offer 告诉设备去哪里连接。

这还不是完整远程桌面体验:客户端 credential strategy、Electron vault、CSP、桌面配对/切换属于后续 P1.4–P1.6。

2变更地图

apps/daemon
2,441 行 · 72.0%
packages/api
569 行 · 16.8%
packages/db
164 行 · 4.8%
apps/cli
140 行 · 4.1%
其余
75 行 · 2.2%
子系统设计重心(要细读)可放心略过
daemoncredential dispatcher、pairing/device service、connection registry、三条 carrier 的鉴权与撤销语义测试 harness 为新 service 补齐依赖
apiprincipal 联合类型、pairing offer、devices/pairing router 与 pairing-only middleware.js source-loader shim、错误映射样板
dbdevices 表的 token hash 与生命周期约束Drizzle snapshot/journal 的生成差异
clibuffin pair 的 JSON 与 TTY 双输出qrcode 引入造成的 lockfile 机械展开
desktop没有生产功能;仅让既有 test fixture 满足扩宽后的 Services29 行 fixture 补位

测试 1,783 行,非测试 1,606 行。没有 rename;主要变更是新增安全承载代码与对应测试,不是机械搬运。

3架构与状态先行

以前 · 单一 runtime token

CLI / Desktop
daemon token
tRPC
Terminal
同一 token
PTY
HTTP blob
同一 token
Blob store

现在 · 三类主体,一处分发

runtime / pairing / device
CredentialVerifier
principal
tRPC + terminal
registry / revoke
deviceId
HTTP blob
每请求重验
Blob store

3.1 principal 不再只是一个字符串

packages/api/src/services.ts认证后的主体形状
export type DaemonRuntimePrincipal = {
  readonly subject: 'daemon-runtime'
}

export type DevicePrincipal = {
  readonly subject: 'device'
  readonly deviceId: string
}

export type VerifiedPrincipal =
  | DaemonRuntimePrincipal
  | DevicePrincipal
  | PairingPrincipal

subject 现在是权限分流的判别字段。尤其要记住:socket 来自 loopback 不代表本地权限,deriveIsLocal 仍要求主体必须是 daemon-runtime

3.2 设备是关系实体,offer 是进程内临时状态

packages/db/src/index.ts持久设备记录
export const devicesTable = sqliteTable('devices', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  platform: text('platform').notNull(),
  tokenHash: text('token_hash').notNull(),
  createdAt: integer('created_at').notNull(),
  lastSeenAt: integer('last_seen_at'),
  revokedAt: integer('revoked_at'),
}, (table) => ({
  tokenHashUnique: uniqueIndex('devices_token_hash_unique').on(table.tokenHash),
}))
apps/daemon/src/services/pairing.ts进程内 offer 记录
type PairingOfferRecord = {
  expiresAt: number
}

const offers = new Map<string, PairingOfferRecord>()
const offerHashByPrincipal = new WeakMap<PairingPrincipal, string>()

持久设备行承载“以后还能不能登录”;pairing offer 只承载“一次 enrollment 还是否有效”。offer 不写 KV、不写 DB,daemon 重启就失效。

3.3 offer 自带信任锚与入口

packages/api/src/pairing.tsPairingOffer 合约
export const pairingOfferSchema = z.object({
  serverId: canonicalBase64url32Schema,
  daemonPublicKey: canonicalBase64url32Schema,
  endpoint: remoteDirectEndpointSchema,
  pairingSecret: pairingSecretSchema,
  expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
})

设备拿到的不只是 secret,还拿到稳定 daemon id、公钥、可连接 endpoint 与过期时间。后续客户端工作会用这些字段把“连到哪里”和“应该是谁”绑定起来。

4共用底座:网络地址与 credential dispatcher

三条旅程都依赖两个底座:daemon 必须能监听远程可达地址;所有 carrier 必须把同一段 credential 解释成同一种 principal。

以前
监听地址写死为 127.0.0.1
每条 carrier 直接比较 runtime token
认证结果只有 daemon-runtime
现在
bindHost 决定 listen;advertisedBaseUrl 决定对外命名
三条 carrier 共享 CredentialVerifier
runtime / pairing / device 三类 principal 进入统一授权层
apps/daemon/src/runtime-auth.ts唯一 credential 分发器
export function createCredentialVerifier(deps: CredentialVerifierDeps): CredentialVerifier {
  return (candidate) => {
    if (!candidate) return null
    if (candidate.startsWith(DEVICE_CREDENTIAL_PREFIX)) {
      return deps.devices.verify(candidate.slice(DEVICE_CREDENTIAL_PREFIX.length))
    }
    if (candidate.startsWith(PAIRING_CREDENTIAL_PREFIX)) {
      return deps.pairing.verify(candidate.slice(PAIRING_CREDENTIAL_PREFIX.length))
    }
    if (candidate.includes(':')) return null
    if (!verifyRuntimeToken(deps.runtimeToken, candidate)) return null
    return { subject: 'daemon-runtime' }
  }
}

未知命名空间直接拒绝,不会退回 runtime token 比较。这使未来新增 credential kind 时不会被旧 daemon 当成另一类凭证。

新增 credential kind 的标准落点:在共享 API 定义 prefix 与 principal 形状 → 在 daemon verifier 增加唯一分支 → 明确哪些 procedure/carrier 接受该 principal → 为 tRPC、terminal、blob 补边界测试。

5旅程 A:本机发布一个配对 offer

全景 · 从配置到终端 QR
网络配置
storage/config.ts
localOnly RPC
api/trpc.ts
offer 内存状态
services/pairing.ts
fragment + QR
commands/pair.ts

A.1监听地址与对外地址被刻意拆开

bindHost 可以是 0.0.0.0,但它不能出现在配对链接里;advertisedBaseUrl 必须是客户端实际能到达、并满足 remote-direct TLS 规则的 URL。

apps/daemon/src/storage/config.tsnetwork 配置
network: z.object({
  bindHost: bindHostSchema.default('127.0.0.1'),
  advertisedBaseUrl: advertisedBaseUrlSchema.optional(),
}).default({ bindHost: '127.0.0.1' })

if (env.BUFFIN_BIND_HOST !== undefined) {
  const bindHost = bindHostSchema.safeParse(env.BUFFIN_BIND_HOST)
  if (bindHost.success) merged.network.bindHost = bindHost.data
}

无效字段只被局部丢弃:坏的 bindHost 回到 loopback,坏的 advertisedBaseUrl 变成未配置,其他文件设置继续生效。额外的 hardening 会拒绝 021307064330x7f000001127.1 这类会被底层解析成 IP 的旧式数字主机。

A.2只有本机 runtime principal 能开始配对

packages/api/src/trpc.tspairing router
const pairingRouter = router({
  start: localOnlyProcedure
    .output(pairingOfferSchema)
    .mutation(({ ctx }) => callService(() => ctx.services.pairing.start())),
  claim: publicProcedure
    .input(pairingClaimInputSchema)
    .output(pairingClaimDtoSchema)
    .mutation(({ ctx, input }) => {
      const principal = requirePairingPrincipal(ctx.principal)
      return callService(() => ctx.services.pairing.claim(principal, input))
    }),
})

startclaim 不共享权限:前者必须来自本机 runtime principal;后者必须来自远端 pairing principal。这个非对称性就是 enrollment 的门闩。

apps/daemon/src/services/pairing.tsoffer 生成
start() {
  if (!deps.advertisedBaseUrl) {
    throw new PairingServiceError(PairingServiceErrorCode.notConfigured)
  }
  const now = clock()
  pruneExpiredOffers(offers, now)
  const pairingSecret = Buffer.from(makeRandomBytes(32)).toString('base64url')
  const expiresAt = now + PAIRING_OFFER_TTL_MS
  const offer = pairingOfferSchema.parse({
    serverId: deps.identity.daemonId,
    daemonPublicKey: Buffer.from(deps.identity.publicKey).toString('base64url'),
    endpoint: deps.advertisedBaseUrl,
    pairingSecret,
    expiresAt,
  })
  offers.set(hashPairingSecret(pairingSecret), { expiresAt })
  return offer
}

A.3CLI 把 offer 放进 URL fragment

apps/cli/src/commands/pair.tsTTY 与 JSON 双路径
const offer = await client.pairing.start.mutate()
if (mode === 'json') return { offer, link: null, qr: null }

const link = createPairingLink(offer)
const qr = await QRCode.toString(link, { type: 'terminal', small: true })
return { offer, link, qr }

交互终端显示 link、QR 和 ISO 过期时间;非 TTY 或 --json 只输出原始 offer,不加载 QR 渲染结果。offer 编码位于 #offer=…,不会成为 HTTP request path/query 的一部分。

排查路标 · 旅程 A
症状从哪下手
daemon 仍只监听 loopbackstorage/config.tsloadConfig,再看 index.tsresolveDaemonNetwork
buffin pair 报 pairing 未配置services/pairing.tsstart;检查解析后的 advertisedBaseUrl
JSON 模式混入 QR 文本commands/pair.tsemitTrpcResultjson 投影

6旅程 B:offer 换成持久设备凭证

全景 · 首次远端连接
pairing:secret
runtime-auth.ts
hello gate
api/trpc.ts
single-use claim
services/pairing.ts
device row + token
services/devices.ts

B.1pairing principal 只有两扇门

客户端在任何业务 RPC 之前都要做 daemon.hello,所以 pairing credential 不能只允许 claim;它允许 hello 以完成身份/协议协商,但除此之外只能 claim。

packages/api/src/trpc.tspairing-only middleware
const pairingAccessMiddleware = t.middleware(({ ctx, path, next }) => {
  if (
    ctx.principal.subject === 'pairing' &&
    path !== 'daemon.hello' &&
    path !== 'pairing.claim'
  ) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'Pairing credentials can only negotiate and claim their offer.',
    })
  }
  return next()
})

同一 principal 在 terminal 和 blob 上被直接拒绝;因此拿到配对链接的人不能浏览模型、上传附件或附着终端。

B.2claim 的“原子点”是删除 offer

apps/daemon/src/services/pairing.ts单次消费
claim(principal, input) {
  const normalizedInput = pairingClaimInputSchema.parse(input)
  const offerHash = offerHashByPrincipal.get(principal)
  offerHashByPrincipal.delete(principal)
  if (!offerHash) throw unavailableOfferError()

  const record = offers.get(offerHash)
  if (!record || record.expiresAt <= clock()) {
    offers.delete(offerHash)
    throw unavailableOfferError()
  }

  offers.delete(offerHash)
  try {
    return pairingClaimDtoSchema.parse(
      deps.devices.issue(normalizedInput.name, normalizedInput.platform),
    )
  } catch (cause) {
    throw new PairingServiceError(PairingServiceErrorCode.issuanceFailed, { cause })
  }
}

输入先验证,offer 再删除,设备凭证最后签发。两个已经通过 credential 验证的并发 socket 也只能有一个删到 offer;如果设备写入失败,offer 仍保持已烧毁,调用方必须重新开始配对。

B.3设备 token 明文只出现一次

apps/daemon/src/services/devices.tsissue 与 verify
issue(name, platform) {
  const deviceToken = randomBytes(32).toString('base64url')
  const tokenHash = hashToken(deviceToken)
  const row = seam.run((tx) => {
    return tx.db.insert(devicesTable).values({
      id: createId(new Date(createdAt)),
      name: normalizedName,
      platform: normalizedPlatform,
      tokenHash,
      createdAt,
      lastSeenAt: null,
      revokedAt: null,
    }).returning().get()
  }).result
  return { deviceId: row.id, deviceToken }
}

后续连接携带 device:<token>。daemon 只计算 hash 查唯一索引,并拒绝 revokedAt 非空的行。lastSeenAt 最多每 60 秒写一次;这项 presence 更新失败时不会推翻已经成功的认证。

排查路标 · 旅程 B
症状从哪下手
pairing socket 在 hello 前就被拒绝runtime-auth.ts 的 prefix dispatch 与 trpc.ts 的 pairing middleware
同一 offer 看似被消费两次services/pairing.tsofferHashByPrincipal 和同步 offers.delete
device token 签发了但后续不能认证services/devices.tsissue/verify,再查 devices.token_hashrevoked_at

7旅程 C:撤销设备并终止访问

全景 · 管理动作到 carrier 行为
devices.revoke
api/trpc.ts
SQLite commit
services/devices.ts
closeFor(deviceId)
connection-registry.ts
tRPC / terminal 关闭 blob 后续请求拒绝

C.1授权失效先成为持久事实

apps/daemon/src/services/devices.tsrevoke 顺序
revoke(deviceId) {
  const device = seam.run((tx) => {
    const current = tx.db.select().from(devicesTable)
      .where(eq(devicesTable.id, deviceId)).get()
    if (current.revokedAt !== null) return toDeviceDto(current)

    const row = tx.db.update(devicesTable)
      .set({ revokedAt: Math.max(clock(), current.createdAt, current.lastSeenAt ?? 0) })
      .where(and(eq(devicesTable.id, deviceId), isNull(devicesTable.revokedAt)))
      .returning().get()
    return toDeviceDto(row)
  }).result
  connections.closeFor(deviceId)
  return device
}

closeFor 在事务结果返回后才执行。于是 socket 被关闭时,下一次认证已经能从数据库观察到撤销,而不是出现“连接断了但 token 仍短暂有效”的反向窗口。

C.2connection registry 只跟踪长连接 carrier

apps/daemon/src/trpc/ws.tstRPC socket 注册
const unregister = deps.connections.register(principal, {
  close: () => opts.res.terminate(),
  forceClose: () => opts.req.socket.destroy(),
})
opts.res.once('close', unregister)
apps/daemon/src/connection-registry.ts撤销与迟到连接
register(principal, termination) {
  const deviceId = deviceIdOf(principal)
  if (deviceId === null) return () => {}
  // 建立 registration,并按 deviceId 放进 Set
  if (revokedDeviceIds.has(deviceId)) attemptClose(deviceId, registration)
  return () => deactivate(deviceId, registration)
},
closeFor(deviceId) {
  revokedDeviceIds.add(deviceId)
  const current = registrations.get(deviceId)
  if (!current) return
  for (const registration of [...current]) attemptClose(deviceId, registration)
}

registry 还维护 transport close、carrier force-close 与有界重试。即使一个已撤销设备在数据库状态切换后才完成 socket 注册,revokedDeviceIds 也会让它立刻进入关闭路径。

C.3blob 不登记连接,而是在每个请求开头重验

apps/daemon/src/routes/blob.ts请求级认证
app.use('/blob/:sessionId', async (c, next) => {
  c.set('principal', verifyBlobPrincipal(
    credentialVerifier,
    c.req.header(DAEMON_TOKEN_HEADER) ?? null,
  ))
  requireCurrentProtocol(c.req.header(PROTOCOL_VERSION_HEADER))
  await next()
})

function verifyBlobPrincipal(verifier, candidate) {
  const principal = verifier(candidate)
  if (!principal || principal.subject === 'pairing') {
    throw new AppError({ code: BuffinErrorCode.auth.daemonRequired })
  }
  return principal
}

已经通过认证并拿到 response stream 的 blob 下载允许完成;撤销只影响之后开始的请求。这里没有 per-principal AbortController,也不会由 registry 中断流。

排查路标 · 旅程 C
症状从哪下手
撤销后 token 仍能新建连接services/devices.tsverify 查询条件与 revoke 提交顺序
某个 device 的 tRPC/terminal 不断开connection-registry.ts 的 registration/retry 状态;再看 carrier 是否用同一 deviceId 注册
撤销中途的 blob 下载仍完成这是固定语义;看 routes/blob.ts 的请求级 gate 与 blob-route.test.ts 的 mid-transfer 用例

8计划 vs 实现的偏差

计划实际做成原因 / 含义
建议把 P1.1、P1.2、P1.3 分成三个 PR同一 PR 内用三个主要 feature commit 落地,再追加 listener hardening 与文档 commit共享 verifier 和真实 WS 集成测试跨越三块边界;PR 仍保持 commit 级分层
registry 只描述 register(principal, close)closeFortermination 拆成 transport close + carrier force-close,并加入有界自动重试、取消与迟到注册处理PR 的安全 hardening 目标扩展了“立即撤销”的运行时保证
bindHost 接受 IP/hostname,非法值回退额外识别并拒绝旧式数字 IP 表示;单独有一个 fix commit 和生产 boot 测试避免 0、整数、十六进制、缩写点分地址被底层解析为意外监听地址
P1 完整目标包含客户端 strategy/vault、CSP 与桌面远程 UX本分支停在 daemon/API/CLI 的 P1.1–P1.3;desktop 只有 fixture 更新这是分支范围边界,不应把“pairing 后能拿到 token”误读成“桌面已能远程使用 daemon”

前三条“为什么”依据提交顺序、PR 描述和随附计划作出的实现意图归纳;第四条由实际文件范围直接确认。

9心智模型补丁

daemon 只认识一枚本机 runtime token。认证结果是 runtime、pairing、device 三类主体,授权看 principal,不看原始 credential。
loopback socket 可以近似理解为“本地用户”。isLocal 必须同时满足 loopback 地址与 daemon-runtime principal。
监听地址就是客户端连接地址。bindHost 只负责 listen;advertisedBaseUrl 才是 offer 对外承诺的 endpoint。
拿到配对 secret 就像拿到完整 daemon token。pairing principal 只能 hello + claim,且不能进入 terminal/blob 数据面。
撤销只需要让下一次 credential 验证失败。撤销提交后还会按 deviceId 主动终止 tRPC 与 terminal 长连接。
所有 carrier 的撤销时刻相同。WS 是连接级关闭;blob 是请求级重验,已授权的传输允许完成。
pairing 完成意味着远程桌面路径已经可用。本分支只完成 server/API/CLI 半环;客户端 vault、CSP、选端与 enrollment UI 仍在后续切片。

10新词表

认证与配对
CredentialVerifier把 carrier 上的一段 opaque credential 解析成唯一 principal 的 daemon 级分发函数。
pairing principal由未过期 offer secret 证明的短期身份,只能协商 daemon 并消费自己的 offer。
device principal由持久、可撤销 device token 证明的身份,携带稳定 deviceId
pairing offer包含 daemon 身份、公钥、endpoint、单次 secret 与过期时间的 enrollment 包。
网络与生命周期
bindHostdaemon 实际交给 Node listen() 的本地地址。
advertisedBaseUrldaemon 告诉远端客户端“从这里找我”的 remote-direct URL。
connection registrydeviceId 记录 live WS 关闭句柄,使撤销能主动切断连接。
remote-direct不经过 relay、由 TLS 前置层直接暴露 daemon 的远程连接方式。

11测试与风险地图

有兜底的

  • config 默认值、文件/env 分层、非法 endpoint 局部回退、旧式数字主机拒绝
  • 生产入口按配置 host 真正监听,并能从 loopback 访问 health
  • device token 32-byte、仅 hash 落库、lastSeen coalescing、rename/revoke
  • offer TTL、单次消费、并发 claim 只成功一个、issuance failure 后保持烧毁
  • 真实 tRPC WS 的 pairing → claim → device → revoke 全链路
  • terminal 接受 device、拒绝 pairing,并在 revoke 时硬关闭
  • blob 接受 device、拒绝 pairing,撤销前已授权的流完成、后续请求失败
  • CLI JSON 不生成 QR,TTY 输出 fragment link 与 terminal QR

薄冰 / 尚未进入本分支

  • 🟠没有客户端 device credential strategy 与 Electron 加密 vault(P1.4)
  • 🟠没有 remote origin CSP、桌面 enrollment、daemon 切换 UI(P1.5–P1.6)
  • 🟡可见测试没有两台真实机器上的 Tailscale / TLS-fronted smoke
  • 🟡预发布 baseline dev-recovery 会重建 DB,因此可丢失已登记 device;identity 文件仍保留
  • 撤销不打断已经开始的 blob response stream,这是明确语义而非遗漏
  • P2 relay、E2EE、blob tunnel 完全不在本分支
验收边界:可以验收“server 端 pairing/device 权限闭环”和“remote-direct 所需监听基础”,不能用这个分支单独验收“桌面 A 操作 daemon B”的完整 P1 exit criterion。

12验收提示与覆盖声明

别被这些变化误导

覆盖声明

主力逐文件读取了 origin/main...HEAD 的全部 56 个文件 diff(3,389 行变更),没有抽样;对 pairing、device、credential dispatch、network config、tRPC/terminal/blob carrier、connection registry 和 CLI 的当前文件进行了二次精读,并读取 base 侧 runtime auth、WS context、listener/config 以确认 before 管线。

随附英文 relay 设计、P0 review、P1 implementation plan 与对应中文镜像的结构均已核对;计划内容只用于动机、范围与偏差,不作为代码事实替代。生成的 migration snapshot 与 lockfile 已全量看过,但按机械生成物处理。

本次产物验证:bun run check 通过;围绕 API、CLI、daemon config、pairing/device service、真实 tRPC WS、terminal、blob 与 production listener 的 13 个测试文件共 214 个测试通过。