所有者密钥对认证(Ed25519 Sigv1)

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

父级:access-control

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)。

sequenceDiagram
  participant O as owner (admin UI)
  participant B as backend
  participant C as MCP client (holds private PEM)
  O->>B: POST /api/admin/keypairs (session cookie)
  B-->>O: private_key_pem — returned ONCE, never stored
  C->>B: /mcp/* with Sigv1 keyId,ts,nonce,sig
  B->>B: ts within ±5min? pubkey lookup? ed25519.Verify(challenge)? nonce unseen?
  B-->>C: ownerID ctx → tool handlers

安全模型

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"——下面的代码并非如此;以代码为准。)

类视图

classDiagram
  class owner_keypairs {
    <<postgres - public half only>>
    id uuid PK
    owner_id uuid FK
    key_id text UNIQUE
    public_key_pem text
    label text
    last_used_at timestamptz NULL
    last_used_ip text NULL
    last_used_user_agent text NULL
    created_at timestamptz
  }
  class CreateKeypair {
    <<func>>
    takes ctx, KeypairDeps, *CreateKeypairInput
    returns (CreatedKeypair, error)
    ed25519.GenerateKey server-side
    private PEM in the return value ONCE, never stored
  }
  class VerifySigv1 {
    <<func>>
    takes ctx, KeypairDeps, authHeader, usedIP, usedUA
    returns (ownerID string, error)
    ts within 5min skew
    pubkey lookup by key_id - miss = plain 401
    ed25519.Verify on the fixed challenge
    nonce first-seen in Redis - fail-open
  }
  CreateKeypair --> owner_keypairs : persists public half
  VerifySigv1 ..> owner_keypairs : lookup + touch last_used_at/ip/user_agent

Setup token(一个独立的子系统)

setup token 是另一回事——它是首次启动时对实例的认领凭证(启动时打印 + 写入 /srv/first-run.txt,sha256 后存入 instance_settings,被 POST /api/admin/claim 消费一次,由此创建 owner)。它负责引导出 owner 账号;密钥对是之后才通过已认证的 admin API 生成的(confusables)。

交叉链接:trusted-identity-via-meta、acl-and-quota-granularity。

相关笔记