Embed 凭据永不携带 code

更新于 · 在 sijie.xyz 查看原条目 ↗

结论(2026-09-01): embed 用一把 每-embed 独立的 Ed25519 密钥、包成 EdDSA JWT 来鉴权访客会话,绝不用 access code 本身。这个 JWT 把四样防伪元素折进去——绑定的 origin、Turnstile 人机验证、过期窗口、一次性 nonce——服务端再把它反查成 embed 的 code_id,这一步在服务端完成。code 明文从不进客户端。整套复用已有机制:owner-MCP 的 Sigv1 栈(Ed25519 验签 + withinSkew + Redis 一次性 nonce)、Turnstile 的 CaptchaVerifier、以及三条现成的撤销路径。golang-jwt/jwt/v5 已在 go.mod 里。唯一净新增的存储是 embed 上 key-id + 公钥这一对列。

触发问题。 <standmeet-chat> widget 跑在一个 StandMeet 管不到的站上,而它的会话必须落到一张 access code 上——code 才是承载 role → 语料 ACL + 能力的东西(今天由 embed-widget-carries-code-capabilities.spec.ts 证明)。但 code 是一个可复用的秘密:它同时被印在简历二维码上、被邮件发给招聘者,撤销它会一次性杀掉所有这些用途。把它写进 widget 的 JS——宿主页上的公开 HTML——就等于把这个可复用秘密暴露给任何读页面源码的人。已经上线的来源白名单收窄了 code 在哪里能用,但它检查的 Origin 头只在浏览器里有效;裸 curl 就能伪造。两个缺口:code 被暴露,闸门在非浏览器下可绕过。

方案——一个指向 code 的间接凭据

  • 每-embed 一把 Ed25519 密钥对。 服务端生成;只存公钥,在 embed 行上(embeds.public_key);私钥嵌进这一个 embed 自己被服务出去的 JS 里。客户端里那样东西是一把映射到 code 的签名密钥——不是 code。多个 embed 可以指向同一张 code(各有各的密钥);一把密钥泄露只毁一个 embed,动不了 code、也动不了它的兄弟 embed。
  • 用 EdDSA JWT 当信封,把每一样防伪元素折进签名 claims:
claim 作用
kid(头部) 选出该 embed 的公钥
iss / sub embed id
iat + exp 短有效窗(2 分钟——sdk/packages/embed/src/embed.ts:374 里 exp = iat + 120;烧掉的 jti 记录活 10 分钟)
jti 一次性 nonce,首次接受即在 Redis 里烧掉
origin 宿主 origin,调用时从 window.location.origin 现取
cnf sha256(turnstile_token)——把一次 Turnstile 验证绑进签名
  • 服务端按顺序验——插在 preIssueBlocked/OriginCheckForCode 里,那儿已经在读 Origin 头、也已经拿到了 embed 行:
  1. 服务端硬钉 alg=EdDSA。 绝不让 token 头部自己挑算法——JWT 的经典坑(alg:none、以及把公钥当 HMAC 密钥的 RS256→HS256 混淆)。这是硬规矩,也是一条测试。
  2. kid →从我们自己的库里取 embed 公钥;未知/缺失 kid → 拒。
  3. 验 EdDSA 签名。
  4. exp/iat 在窗内,否则拒——截获的 token 会过期。
  5. jti 没见过 → 烧掉;Redis 不可用时 fail-closed。(owner-MCP 那条路是 fail-open——对 owner 可接受,对公开不可信面是错的。)
  6. origin claim ==浏览器带的 Origin 头(页面 JS 改不了它)== embed 的白名单。
  7. 独立地用 Turnstile secret 验 Turnstile token;cnf 哈希把它绑死在这一个 JWT 上。
  8. 反查 embed → code_id → 按那张 code 的 role 发会话。

复用 vs 净新增

  • 复用: Ed25519 生成 + 只存公钥(owner keypair,owner/usecase/keypairs.go);Sigv1 验签思路 + withinSkew + Redis 一次性 nonce(VerifySigv1);Turnstile 的 CaptchaVerifier;三种撤销(keypair 硬删 → 立刻失效 / status='revoked' / code→session 的 Redis 清场,RevokeCode→DeleteByCode)。golang-jwt/jwt/v5 已 vendored。
  • 净新增: embeds 上一个 public_key 列;把 Ed25519 验签器泛化成接受 embed 范围 的密钥源、并接进 POST /api/v1/sessions(今天这条路只按 code 明文相等鉴权);embed.ts 里 ~20 行的 EdDSA JWT 签名器(取 origin、拿 Turnstile token、签名)。

诚实的天花板

私钥待在公开 JS 里,所以可被提取——这是 embed 跑在第三方站的物理,去不掉。这套设计真正买到的,精确地说:

  • code 明文从不出现在 JS、请求、或任何响应里;
  • 每个 embed 的密钥可独立撤销——泄露被控制在一个 embed 内,撤它不碰 code 也不碰兄弟;
  • 截获的请求重放即死(一次性 jti)且短命(exp);
  • 裸 curl 不过 Turnstile 就调不了这个端点,把成本从一行抬到"养一条刷验证码的自动化"——而 limit_per_period 速率闸加撤销兜住漏过来的部分。

这是缩小爆炸半径 + 抬高成本 + 一键 kill,不是不可破的保密。说成别的就是安全剧场:在 JS 里自带一步"防伪算法"毫无价值(curl 把 JS 照跑一遍就复现);唯一真正的"证明你是浏览器"原语是服务端可验证的 attestation(Turnstile),所以浏览器闸是它、而不是某种指纹。

长期成立的点

  • 一 embed 一 code。 embeds.code_id 是 UNIQUE(2026-09-01 上线)——一张 code 最多被一个 embed 暴露,所以它的白名单/密钥是唯一权威。只在有意为之时放宽;一旦重开,"哪把密钥 / 哪份白名单"必须保持无歧义。
  • 与已上线部分的关系。 来源白名单(OriginAllowed、OriginCheckForCode、403 origin_not_allowed)和 limit_per_period 已经在了。本设计是下一层——它关掉白名单单独留下的两个缺口:code 暴露 与 非浏览器绕过。

已实现 2026-09-01。 embeds.key_id/public_key 列(migration embed-signing-key);EmbedRepo.AuthByKeyID;access/usecase.VerifyEmbedToken(golang-jwt/v5,硬钉 alg=EdDSA、exp 必填、jti 在共用 Redis nonce store 上 fail-closed);接进 sessions_guard.go 的 embedTokenBlocked;embed.ts 浏览器签名器(WebCrypto Ed25519)+ admin 一次性 snippet 弹窗。测试:embed-token-auth.spec.ts(8 条:合法→反查出 code,重放/origin 不符/不在白名单/过期/错密钥/alg-none/已撤销 全拒)+ upgrade-embed-schema 覆盖第三个 migration。Turnstile 绑定(cnf)是唯一延后的一层——dev 里 captcha 关着;给公开面开 captcha 时再折进去。

范围修正(同日)。 来源白名单只闸 widget/token 这条路。直接用明文 code(QR / 分享链接 / 手粘)不受 origin 闸——给一张 code 做了 embed 不能把它的直接用法锁死,而且在 JWT 设计下 code 本来也不会出现在合作方站上。旧的明文路径 origin 检查(OriginCheckForCode)已删除;守卫:embed-direct-code-stays-open.spec.ts(直接用法在实例自己的 origin、白名单之外的 origin、以及没有 Origin 头时都能通)。这是闸门粒度拿掉了一个能用的动作那类失败:闸比动作粗了一档,悄悄拿掉了一个本来能用的动作(QR/直接用),而 CI 一直是绿的。

真 embed 验证。 把 snippet 注进一份复制的 example.com,用另一个 origin(localhost:8090,在白名单里)服务出来:widget 挂载、加载 /embed.js(CORS *)、WebCrypto 签名、拿到跨源 200 的会话——而 code 明文在页面和 /embed.js 里都不存在(只有 embed/kid/key)。同一份 snippet 从一个不在白名单的 origin(localhost:8091)服务出来 → 403 + 一句干净的"that did not go through"。dev 里的回答内容是 mock LLM 在复述 system prompt——真正的 owner 口吻回答需要真模型(evals)。

来源:2026-09-01 设计对话;在跑着的 standmeet-new 上实现并验过(真跨源 widget:白名单内的 origin 拿到 200,不在白名单的 origin 拿到 403;服务端接受浏览器 WebCrypto 的签名)。