2026-09-23·by Sijie Wang#software#project#standmeet

embed-credential-never-carries-the-code

Embed 凭据永不携带 code

结论(2026-09-01): embed 用一把 每-embed 独立的 Ed25519 密钥、包成 EdDSA JWT 来鉴权访客会话,绝不用 access code 本身。这个 JWT 把四样防伪元素折进去——绑定的 originTurnstile 人机验证过期窗口一次性 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 / subembed id
iat + exp短有效窗(2 分钟——sdk/packages/embed/src/embed.ts:374exp = iat + 120;烧掉的 jti 记录活 10 分钟)
jti一次性 nonce,首次接受即在 Redis 里烧掉
origin宿主 origin,调用时从 window.location.origin 现取
cnfsha256(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 清场,RevokeCodeDeleteByCode)。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 暴露,所以它的白名单/密钥是唯一权威。只在有意为之时放宽;一旦重开,"哪把密钥 / 哪份白名单"必须保持无歧义。
  • 与已上线部分的关系。 来源白名单(OriginAllowedOriginCheckForCode、403 origin_not_allowed)和 limit_per_period 已经在了。本设计是下一层——它关掉白名单单独留下的两个缺口:code 暴露非浏览器绕过

已实现 2026-09-01。 embeds.key_id/public_key 列(migration embed-signing-key);EmbedRepo.AuthByKeyIDaccess/usecase.VerifyEmbedToken(golang-jwt/v5,硬钉 alg=EdDSA、exp 必填、jti 在共用 Redis nonce store 上 fail-closed);接进 sessions_guard.goembedTokenBlockedembed.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 的签名)。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →