所有者密钥对认证(Ed25519 Sigv1)
owner MCP handle(/mcp/*)对每一个 HTTP 请求都要求有效的 Sigv1 签名——旧的 Bearer PAT 在密钥对认证取代 PAT 时被移除(routes/mcphandle/server.go)。
范围界定: Sigv1 只守卫对外的服务 handle(StandMeet 作为 MCP 服务端,as-mcp-facade)。对内的能力平面(我们的 agent 把 mcp-servers/ 当作 host 来消费)是进程内本地的——stdio 子进程 + capsocket——从不经过这层认证(confusables)。
密钥生命周期
密钥对在服务端生成(ed25519.GenerateKey,backend/internal/owner/usecase/keypairs.go);私钥 PEM 只在 POST /api/admin/keypairs 的响应中返回这一次,此后不再存储——只有公钥持久化在 owner_keypairs 表中。领域层注释写着:"the private key never enters the domain (owner keeps the PEM)"(owner/entity/keypair.go:2)。管理端的增删改查走的是普通的 owner session cookie;列表接口只返回元数据(现在含这把 key 最后在哪里用过——last_used_ip + last_used_user_agent,2026-09-05 上线,169a51d79,migration 2026-09-06-keypair-last-used-meta.sql);DELETE /{key_id} 是硬删除,等同于吊销(没有状态列)。
线上格式
Authorization: Sigv1 keyId=<X>,ts=<unix-seconds>,nonce=<uuid>,sig=<base64>
被签名的消息是一个固定的 challenge,而不是请求本身:
"standmeet-sigv1" + "\n" + keyId + "\n" + ts + "\n" + nonce
验证流程
前缀与四个字段齐全 → ts 落在 ±5 分钟的时钟偏差范围内 → 按 keyId 查公钥(查不到 → 401,且不泄露该 key 是否存在)→ ed25519.Verify → nonce 首见检查(Redis SetNX 到 sigv1nonce:<keyId>:<nonce>,TTL = 2×偏差窗;Redis 挂了则 fail-open)→ 尽力而为地更新 last_used_at + last_used_ip + last_used_user_agent → ownerID 被注入 ctx 并传播给 MCP 的工具处理器(keypairs.go:176-302)。
安全模型
Warning签名并不覆盖请求本身。 challenge 绑定了身份 + 时间戳 + 一次性 nonce——不包含方法、路径或请求体。自 2026-07-04(
0fa5177e1)起,被截获的 header 重放即死(nonce 在首次接受时就在 Redis 里烧掉),但它认证的仍是调用者,而不是每一条消息:窗口内的攻击者只有握有私钥才能给另一个/mcp/*请求配上新签的 header。nonce 检查在 Redis 故障时 fail open——对 owner 面可接受,也正是 embed 那条对应路径要 fail closed 的原因。在当前的威胁模型下(单一 owner、假定有 TLS)这是可以接受的;剩下的升级路径是对method + path + body-hash签名。(keypairs.go:4的文件头注释仍写着"no nonce table"——下面的代码并非如此;以代码为准。)
类视图
Setup token(一个独立的子系统)
setup token 是另一回事——它是首次启动时对实例的认领凭证(启动时打印 + 写入 /srv/first-run.txt,sha256 后存入 instance_settings,被 POST /api/admin/claim 消费一次,由此创建 owner)。它负责引导出 owner 账号;密钥对是之后才通过已认证的 admin API 生成的(confusables)。