PR #180 解读:pairing 与 device access controls
feat/relay-p1-123 · aa07224 · 对比 merge-base a072104 · 56 个文件
1TL;DR
这个分支把 daemon 的认证模型从“只有本机进程 token”扩成三类主体:daemon-runtime、短期 pairing、持久 device。
本机 CLI 可以生成十分钟、单次使用的配对 offer;远端设备用 offer 连接,只能做 daemon.hello 和 pairing.claim,随后换到一枚可撤销的设备 token。
设备 token 的明文只返回一次,数据库只存 sha256;撤销先提交到 SQLite,再关闭该设备已认证的 tRPC 和 terminal WebSocket,后续 blob 请求也会被拒绝。
同时 daemon 的监听地址与对外地址被拆开:bindHost 决定监听,advertisedBaseUrl 决定 offer 告诉设备去哪里连接。
这还不是完整远程桌面体验:客户端 credential strategy、Electron vault、CSP、桌面配对/切换属于后续 P1.4–P1.6。
2变更地图
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| daemon | credential dispatcher、pairing/device service、connection registry、三条 carrier 的鉴权与撤销语义 | 测试 harness 为新 service 补齐依赖 |
| api | principal 联合类型、pairing offer、devices/pairing router 与 pairing-only middleware | .js source-loader shim、错误映射样板 |
| db | devices 表的 token hash 与生命周期约束 | Drizzle snapshot/journal 的生成差异 |
| cli | buffin pair 的 JSON 与 TTY 双输出 | qrcode 引入造成的 lockfile 机械展开 |
| desktop | 没有生产功能;仅让既有 test fixture 满足扩宽后的 Services | 29 行 fixture 补位 |
测试 1,783 行,非测试 1,606 行。没有 rename;主要变更是新增安全承载代码与对应测试,不是机械搬运。
3架构与状态先行
以前 · 单一 runtime token
现在 · 三类主体,一处分发
3.1 principal 不再只是一个字符串
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 是进程内临时状态
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),
}))
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 自带信任锚与入口
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.1daemon-runtimebindHost 决定 listen;advertisedBaseUrl 决定对外命名CredentialVerifierexport 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 当成另一类凭证。
5旅程 A:本机发布一个配对 offer
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。
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 会拒绝 0、2130706433、0x7f000001、127.1 这类会被底层解析成 IP 的旧式数字主机。
A.2只有本机 runtime principal 能开始配对
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))
}),
})
start 与 claim 不共享权限:前者必须来自本机 runtime principal;后者必须来自远端 pairing principal。这个非对称性就是 enrollment 的门闩。
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
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 仍只监听 loopback | storage/config.ts 的 loadConfig,再看 index.ts 的 resolveDaemonNetwork |
buffin pair 报 pairing 未配置 | services/pairing.ts 的 start;检查解析后的 advertisedBaseUrl |
| JSON 模式混入 QR 文本 | commands/pair.ts 与 emitTrpcResult 的 json 投影 |
6旅程 B:offer 换成持久设备凭证
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。
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
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 明文只出现一次
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.ts 的 offerHashByPrincipal 和同步 offers.delete |
| device token 签发了但后续不能认证 | services/devices.ts 的 issue/verify,再查 devices.token_hash 与 revoked_at |
7旅程 C:撤销设备并终止访问
api/trpc.ts→ SQLite commit
services/devices.ts→ closeFor(deviceId)
connection-registry.ts→ tRPC / terminal 关闭→ blob 后续请求拒绝
C.1授权失效先成为持久事实
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
const unregister = deps.connections.register(principal, {
close: () => opts.res.terminate(),
forceClose: () => opts.req.socket.destroy(),
})
opts.res.once('close', unregister)
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 不登记连接,而是在每个请求开头重验
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.ts 的 verify 查询条件与 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) 与 closeFor | termination 拆成 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心智模型补丁
isLocal 必须同时满足 loopback 地址与 daemon-runtime principal。bindHost 只负责 listen;advertisedBaseUrl 才是 offer 对外承诺的 endpoint。deviceId 主动终止 tRPC 与 terminal 长连接。10新词表
| 认证与配对 | |
|---|---|
CredentialVerifier | 把 carrier 上的一段 opaque credential 解析成唯一 principal 的 daemon 级分发函数。 |
pairing principal | 由未过期 offer secret 证明的短期身份,只能协商 daemon 并消费自己的 offer。 |
device principal | 由持久、可撤销 device token 证明的身份,携带稳定 deviceId。 |
pairing offer | 包含 daemon 身份、公钥、endpoint、单次 secret 与过期时间的 enrollment 包。 |
| 网络与生命周期 | |
bindHost | daemon 实际交给 Node listen() 的本地地址。 |
advertisedBaseUrl | daemon 告诉远端客户端“从这里找我”的 remote-direct URL。 |
connection registry | 按 deviceId 记录 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 完全不在本分支
12验收提示与覆盖声明
别被这些变化误导
packages/api/src/devices.js与pairing.js是 Node source TypeScript loader shim,不是第二套实现。bun.lock的 44 行来自qrcode及其依赖树;设计逻辑在 CLI command,不在 lockfile。- Drizzle snapshot 的大块 JSON 与 baseline timestamp 是生成物;真正的新关系模型是
devicesTable。 apps/desktop/tests/trpc-client-session.test.ts的变化只是给扩宽后的Services填 fixture,不代表桌面已接入 pairing。- 工作区 6 份 relay 中英文文档目前是未跟踪文件;本报告用它们解释计划,但它们不属于 PR #180 的提交 diff。
覆盖声明
主力逐文件读取了 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 个测试通过。