目标
两件事:
- 存库加密:库被拖走、或只有读库权限的人,都拿不到明文密码。
- 运行时注入: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 请求):
- 代理已认证 session(
authenticateRuntimeSession)→ code session → workspace → vault_ids。
- 在
vault_ids 的活动凭证里,按出站目标匹配 mcp_server_url;按 vault_ids 顺序,首个命中胜出。无命中 → fail-closed。
- OMA 用 KEK 解
wrapped_dek,再解 token。全程在 OMA 内。
- 删掉沙箱自带的
Authorization(可能是占位符),加 Authorization: Bearer <token>,转发。
- 仅当目标命中该凭证的
networking.allowed_hosts 才注入;不命中不加,也不降级到别的凭证。
- 跨 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)。
实施阶段
验收
不做
- 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 负责,在范围内,不是"不做"。)
参考
目标
两件事:
主密钥本期放
config.yaml(全局一把,多 workspace 共用)。不做分片、不做 KMS;KeyProvider接口先留好,以后再接。背景
Vault CRUD 和 OAuth 注册已经有了,但:
secret_payload直接 jsonb,全仓无加密);OAuth 流程里的client_secret也是明文。vault_ids的 session,沙箱连 MCP 时还是没认证信息,vault 等于空转。本期补这两块:加密存储 + 运行时注入。
威胁模型(加密管到哪)
config.yaml一起丢加密只覆盖前两行。沙箱偷密走注入;进程被拿下是运行时加固,另议;config 一起丢是接受的缺口。
加密方案:信封加密
每条密码用一次性 DEK 加密,DEK 再用 KEK 包一层一起存。换主密钥时只 rewrap DEK,不用重加密全部密码。
字面意思
两层:密码 → 用 DEK(小钥匙) 锁成密文;DEK → 用 KEK(主密钥) 锁起来(wrapped_dek)一起存。
换主密钥(=换 KEK)时:
类比:密码 = 保险箱里的文件(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 的核心价值。
主密钥
本期从
config.yaml读(和 S3/E2B key 一样),也支持_file挂载(同upstream_proxy_ca_key_file)。“主密钥从哪来”做成可替换模块:
KeyProvider+ 启动时通用Prepare。主流程不写死读 config。以后要防 config 一起丢,加 Shamir 或 KMS provider 即可;业务密文、DB 字段、对外接口不用动。数据库
当前相关列:
authsecret_payload新增 migration(编号顺延,例如
00034_add_vault_secret_envelope.sql),只加列,不建表:secret_ciphertextsecret_noncesecret_wrapped_deksecret_format_versionsecret_key_providerlocal/ 以后aws_kms等)secret_key_versionsecret_versionsecret_payloadExpand 期保留双读;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 事务外。轮换
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 请求):
authenticateRuntimeSession)→ code session → workspace →vault_ids。vault_ids的活动凭证里,按出站目标匹配mcp_server_url;按vault_ids顺序,首个命中胜出。无命中 → fail-closed。wrapped_dek,再解 token。全程在 OMA 内。Authorization(可能是占位符),加Authorization: Bearer <token>,转发。networking.allowed_hosts才注入;不命中不加,也不降级到别的凭证。Authorization再跟,token 不带到非预期目标。匹配规则:凭证
mcp_server_url的 path 必须是请求 path 的按/分段前缀。例如…/mcp命中…/mcp、…/mcp/sse,不命中…/mcp-admin。凭证类型:
Sandbox 拿到的
mcp_config只有服务器 URL,没有 token。真 token 只在 OMA 代理里瞬态出现。无匹配凭证一律拒绝,包括公开 MCP。要用公开服务也得建凭证。现有类型都要求带秘密——“无认证”怎么表达还没定(空 token 的
static_bearer,或以后加none)。实施阶段
docs/design/be/vault-runtime.md(威胁模型、接口、失败测试)验收
config.yaml,不进 DB/沙箱/日志。(注:mcp_oauth_flows的 client_secret/code_verifier 本期仍明文,拆小 issue——见"不做"。)mcp_server_url**path 前缀(按/分段)**匹配;无匹配凭证 → 严格 fail-closed 拒绝(含公开 MCP);redirect 跨域剥 token 不泄漏。go test ./... -count=1、相关包go test -race;just lint、just dead-code、just duplicates、just complexity。不做
config.yaml,不实现分片;c₁ 防护留作将来 provider)。mcp_oauth_flows加密(本期不做,拆小 issue;vault_credentials零明文即可)。参考
vault/barrier_aes_gcm.go、shamir/