Skip to content

[Vault][#65 子任务] 完成 Vault Secret 加密与运行时凭据安全交付 #142

Description

@qifanlili

目标

两件事:

  1. 存库加密:库被拖走、或只有读库权限的人,都拿不到明文密码。
  2. 运行时注入:Sandbox 调需要认证的 MCP/API 时能用上密码;Sandbox 本身永远看不到真密码。

主密钥本期放 config.yaml(全局一把,多 workspace 共用)。不做分片、不做 KMS;KeyProvider 接口先留好,以后再接。

背景

Vault CRUD 和 OAuth 注册已经有了,但:

  • 密码明文落库(secret_payload 直接 jsonb,全仓无加密);OAuth 流程里的 client_secret 也是明文。
  • 运行时不会注入凭证。挂了 vault_ids 的 session,沙箱连 MCP 时还是没认证信息,vault 等于空转。

本期补这两块:加密存储 + 运行时注入。

威胁模型(加密管到哪)

场景 加密能否挡住
数据库被偷(备份/磁盘) 能
只有查库权限的人偷看 能
数据库 + config.yaml 一起丢 不能。主密钥就在 config 里,以后分片/KMS 再管
打进运行中的 OMA 进程 不能。本期不解决
沙箱里靠 prompt injection 骗 Agent 偷密码 加密不管。靠注入设计:沙箱只见占位符,真密码只在 proxy 里瞬态出现

加密只覆盖前两行。沙箱偷密走注入;进程被拿下是运行时加固,另议;config 一起丢是接受的缺口。

加密方案:信封加密

每条密码用一次性 DEK 加密,DEK 再用 KEK 包一层一起存。换主密钥时只 rewrap DEK,不用重加密全部密码。
字面意思

两层:密码 → 用 DEK(小钥匙) 锁成密文;DEK → 用 KEK(主密钥) 锁起来(wrapped_dek)一起存。

换主密钥(=换 KEK)时:

  • 有 DEK 层:每条 DEK 用旧 KEK 解开、新 KEK 重包(rewrap)。密码密文不动(它是 DEK 锁的,DEK 没变),每行只改小块 wrapped_dek。

类比:密码 = 保险箱里的文件(DEK 锁的);DEK = 文件箱钥匙,放小保险柜(KEK 锁的)。换保险柜钥匙时:只需把文件箱钥匙从旧柜挪到新柜(rewrap DEK),文件箱本身不用打开重锁。
细节:

  • AES-256-GCM,每次新 nonce,带 auth tag。

  • AAD 绑 org/workspace/vault/凭证;搬走就解不开。故意不绑 KEK 版本,方便换主密钥时密文不动。

  • 密文头带 key version,老数据自带“用几号钥匙锁的”。

  • 解不开就报错。不退化明文,不换别的 key 凑合。

    留 DEK 层的真正原因是:将来接 KMS 时,密码明文永远不过 KMS——KMS 只 wrap 那把随机 DEK(无意义数据),真密码不出 OMA。这才是信封对 vault 的核心价值。

主密钥

vault:
  master_key:
    kek: <32 字节, base64>   # 本期直接配
    # 以后可换: provider: shamir | kms | openbao

本期从 config.yaml 读(和 S3/E2B key 一样),也支持 _file 挂载(同 upstream_proxy_ca_key_file)。

“主密钥从哪来”做成可替换模块:KeyProvider + 启动时通用 Prepare。主流程不写死读 config。以后要防 config 一起丢,加 Shamir 或 KMS provider 即可;业务密文、DB 字段、对外接口不用动。

type KeyProvider interface {
    Prepare(ctx) error
    WrapDEK(...) (wrappedDEK, error)
    UnwrapDEK(...) (dek, error)
}

数据库

当前相关列:

列 现状
auth jsonb,非秘密(如 mcp_server_url),不动
secret_payload jsonb,明文秘密,要换掉

新增 migration(编号顺延,例如 00034_add_vault_secret_envelope.sql),只加列,不建表:

列 类型 说明
secret_ciphertext bytea AES-GCM 密文(含 tag)
secret_nonce bytea(12) nonce
secret_wrapped_dek bytea 被 KEK 包过的 DEK
secret_format_version int 密文/AAD 格式版本
secret_key_provider text provider 名(local / 以后 aws_kms 等)
secret_key_version bigint 用几号 KEK
secret_version bigint not null default 0 CAS 乐观锁

secret_payload Expand 期保留双读;Backfill 后清空;Contract 期再删列。

信封列均可空(和 secret_payload 共存)。CHECK:secret_ciphertext 非空时,nonce / wrapped_dek / key_provider / key_version 必须非空。Contract 删 secret_payload 后再收紧。

KEK 版本由 config 管(current + decrypt_only 旧列表)。每条凭证用 secret_key_version 标明自己用的是哪把。KEK 本身不进库。

mcp_oauth_flows 里的 client_secret / code_verifier 也是明文(15min TTL)。若验收要求“DB 零明文”,同样走 Secret Service(该表加一组精简信封列,两者复用同一 DEK)。生命周期短,也可拆小 issue。

迁移节奏:Expand(加可空列)→ 代码双读 + CAS → Backfill(加密全表、清 secret_payload)→ 回读校验 → Contract(删列、收紧约束)。Provider/KMS 调用放在 DB 事务外。

轮换

层 策略
DEK 每次写/改/刷新密码都换新 DEK。v1 的“轮换”就发生在这层,不用定时任务
KEK v1 不做轮换

KEK 不做的原因:config.yaml 模式下旧 key 很难干净销毁,轮换收益有限;v1 价值在防库泄露,不靠轮换。Backfill 基础设施 anyway 要建,应急 re-key 可以复用(旧 KEK 解 wrapped_dek → 新 KEK 重包 → CAS 更新;密文/nonce/AAD 不动)。诚实讲:config 模式下这次 re-key 也不干净。

正式轮换等接 KMS 再说(那时才能真正 DisableKey)。secret_key_version 列保留,v1 恒为 1。

运行时注入

目标:Sandbox 调需认证的 MCP 时,由 OMA 在出站请求里注入凭证;Sandbox 只看到 MCP URL,看不到 token。

注入点用现有 CCRv2 上游代理(已支持 MITM)。Sandbox 出站 HTTPS 经代理:终结 TLS → 见明文 HTTP → 加 Authorization → 再 TLS 转发给真实 MCP。Sandbox 信任代理 TLS,是因为信任 OMA 的 MITM CA(upstream_proxy_ca_key_file 已就绪)。现状:Rewrite 只改 URL,不加 Authorization——本期补这一步。

流程(每次出站 MCP 请求):

  1. 代理已认证 session(authenticateRuntimeSession)→ code session → workspace → vault_ids。
  2. 在 vault_ids 的活动凭证里,按出站目标匹配 mcp_server_url;按 vault_ids 顺序,首个命中胜出。无命中 → fail-closed。
  3. OMA 用 KEK 解 wrapped_dek,再解 token。全程在 OMA 内。
  4. 删掉沙箱自带的 Authorization(可能是占位符),加 Authorization: Bearer <token>,转发。
  5. 仅当目标命中该凭证的 networking.allowed_hosts 才注入;不命中不加,也不降级到别的凭证。
  6. 跨 origin redirect:先剥 Authorization 再跟,token 不带到非预期目标。

匹配规则:凭证 mcp_server_url 的 path 必须是请求 path 的按 / 分段前缀。例如 …/mcp 命中 …/mcp、…/mcp/sse,不命中 …/mcp-admin。

凭证类型:

  • static_bearer:固定 token,直接注入。v1 MVP 先打通。
  • mcp_oauth:检查 access_token 过期;快过期则用 refresh_token + token_endpoint 刷新后再注入。static_bearer 之后做。
  • env_var:往沙箱环境变量塞占位符、出口替换。官方说 self-hosted 沙箱不支持(依赖 Anthropic 管控出口)。OMA 用 E2B → v1 先不做,先聚焦 MCP 注入。

Sandbox 拿到的 mcp_config 只有服务器 URL,没有 token。真 token 只在 OMA 代理里瞬态出现。

无匹配凭证一律拒绝,包括公开 MCP。要用公开服务也得建凭证。现有类型都要求带秘密——“无认证”怎么表达还没定(空 token 的 static_bearer,或以后加 none)。

实施阶段

  • 阶段 0:新建 docs/design/be/vault-runtime.md(威胁模型、接口、失败测试)
  • 阶段 1:密码服务 + 加密 + 本地 provider(主密钥来自 config.yaml) + 契约测试
  • 阶段 2:DB 字段(7 列,无新表)+ 清明文(Backfill)
  • 阶段 3:vault API 收口(写走加密、读只返回说明信息)
  • 阶段 4:运行时 Transport 注入(static_bearer E2E)
  • 阶段 5:OAuth 刷新 + env 占位符(若 E2B 支持)

验收

  • vault_credentials 零明文:DB、沙箱、日志、trace 不出现明文密码、小钥匙;主密钥只在 config.yaml,不进 DB/沙箱/日志。(注:mcp_oauth_flows 的 client_secret/code_verifier 本期仍明文,拆小 issue——见"不做"。)
  • 写密码统一经密码服务;读只返回说明信息,不返回密码。
  • 篡改密文/nonce/wrapped_dek/AAD 后解密失败;格式未知或钥匙不可用 → fail closed。
  • 主密钥来自 config.yaml,防住数据库泄露(a/b);provider 接口可替换(将来接 Shamir/KMS 不改信封/DB/API)。
  • 沙箱拿不到真 token——只看到 MCP 服务器 URL;真 token 只在 OMA 代理瞬态出现,不进沙箱/config/日志。
  • 注入按 mcp_server_url **path 前缀(按 / 分段)**匹配;无匹配凭证 → 严格 fail-closed 拒绝(含公开 MCP);redirect 跨域剥 token 不泄漏。
  • 跨 workspace/session、未挂 vault、错误目标 → fail closed。
  • 完整 E2E、go test ./... -count=1、相关包 go test -race;just lint、just dead-code、just duplicates、just complexity。

不做

  • Shamir 分片(本期主密钥来自 config.yaml,不实现分片;c₁ 防护留作将来 provider)。
  • KEK 轮换功能(v1 不做;DEK 写入时自动换保留;应急 re-key 走 backfill 式一次性任务,见 §5)。
  • mcp_oauth_flows 加密(本期不做,拆小 issue;vault_credentials 零明文即可)。
  • 云厂商 KMS / Vault Transit / OpenBao adapter(只留 §7 接口,另立 issue)。
  • 重做 vault CRUD、管理页面、MCP Catalog/Permission/Confirmation。
  • 不自建 KMS/HSM 控制面。
  • c₂(打进 OMA 程序)的防护——OMA 本就持有密钥,属运行时加固,不在本 issue。(d 由注入线 §6 负责,在范围内,不是"不做"。)

参考

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions