Embed 凭据永不携带 code
结论(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/subembed id iat+exp短有效窗(2 分钟—— sdk/packages/embed/src/embed.ts:374里exp = iat + 120;烧掉的jti记录活 10 分钟)jti一次性 nonce,首次接受即在 Redis 里烧掉 origin宿主 origin,调用时从 window.location.origin现取cnfsha256(turnstile_token)——把一次 Turnstile 验证绑进签名
- 服务端按顺序验——插在
preIssueBlocked/OriginCheckForCode里,那儿已经在读Origin头、也已经拿到了 embed 行:
- 服务端硬钉
alg=EdDSA。 绝不让 token 头部自己挑算法——JWT 的经典坑(alg:none、以及把公钥当 HMAC 密钥的 RS256→HS256 混淆)。这是硬规矩,也是一条测试。kid→从我们自己的库里取 embed 公钥;未知/缺失kid→ 拒。- 验 EdDSA 签名。
exp/iat在窗内,否则拒——截获的 token 会过期。jti没见过 → 烧掉;Redis 不可用时 fail-closed。(owner-MCP 那条路是 fail-open——对 owner 可接受,对公开不可信面是错的。)originclaim ==浏览器带的Origin头(页面 JS 改不了它)== embed 的白名单。- 独立地用 Turnstile secret 验 Turnstile token;
cnf哈希把它绑死在这一个 JWT 上。- 反查 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、403origin_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 的签名)。