Skip to content

密钥托管与恢复

WireGuard 节点密钥的保管方式、可选的离线托管,以及丢失后的恢复路径。

设计见 fleet 供给收权安全模型,工具参数见 CLI 与脚本


密钥在哪里

控制面数据库是唯一事实源。 node_wireguard_keys 表按节点存一对 X25519 密钥,私钥以 base64 明文列存放;materialize 时无条件注入每个 WG 接口的 private_key_ref 下发给 agent。

节点不生成、不持有独立密钥:agent 拿到的私钥来自 DesiredState,渲染进 rendered/wireguard/<iface>.conf 后 bind 挂载给 wg-gateway 容器。状态目录里没有单独的密钥文件,但渲染出的 conf 本身就含明文私钥——实测文件模式 0644、目录链 0755,节点上任何本地用户可读。

这带来一个必须正视的结论:

⚠️ 密钥的暴露面有两处:控制面数据库(node_wireguard_keys.private_key 明文列),以及每一台节点的渲染目录。库备份、对公网发布的 PostgreSQL 端口、能读该表的凭据、节点上的本地用户——都要按「持有全网隧道密钥」的级别对待。

换取的是密钥口径的收敛——此前私钥以副本散落在每个接口 spec 里、节点各自生成,公钥分叉过。取舍与迁移见 fleet 供给收权


一致性闸门(始终生效)

agent 每轮 reconcile(apply 模式且接入控制面时)会做一次公钥对账:

  1. 从 DesiredState 的 WG 接口取节点密钥,推导公钥;
  2. POST /control/v1/agent/wireguard-keys 上报;
  3. 控制面与 Node.wireguard_public_key(密钥表公钥的只读投影)比对。
结果含义
stored首次登记。同事务把「对端是本节点」的内部 peering 重新物化,让对端拉到新公钥
matched一致,放行
rejected不一致 → 409 并回滚事务,agent 抛错中止本轮 reconcile

409 的意义是:节点绝不用偏离的密钥拉隧道。遇到它要查清哪一侧是对的,而不是绕过——要改密钥只能走显式轮换(set_node_key 加全接口重物化)。


离线托管(可选,当前生产未启用)

托管在密钥落库之外再存一份用离线公钥加密的副本,用于「数据库本身也没了」的场景。

机制完整存在于代码中,但生产实例没有配置 [server] recovery_public_key,因此:

  • GET /control/v1/agent/recovery-public-key 返回 {"configured": false}
  • agent 不做封装,上报里 private_key_escrownull
  • nodes.wireguard_private_key_escrow 列恒为空。

要启用就补配恢复公钥,agent 下一轮 reconcile 即开始上交密文。

一、离线生成恢复密钥对(只做一次)

在一台离线、可信的机器上——不是控制面,也不是节点:

bash
python deploy/tools/dn42-recover/dn42_recover.py keygen --out-dir ./recovery-keys
# 产出:recovery-private.pem(口令加密,离线妥善保管)
#       recovery-public.pem(交给控制面)

recovery-private.pem 用口令加密,放保险箱或密码管理器,绝不上传任何服务器

二、把恢复公钥配给控制面

toml
# control-server.toml
[server]
recovery_public_key = "/etc/dn42-control/recovery-public.pem"
# 也可直接内联 PEM 文本

取值既可以是文件路径也可以是内联 PEM(以 -----BEGIN 开头判定)。给的是路径且文件不存在时启动期 fail-fast

配好后控制面经 GET /control/v1/agent/recovery-public-key 把公钥与指纹下发给 agent。

三、agent 自动上交密文

下一轮 reconcile 起,agent 在上报公钥的同时用 RSA-OAEP 把私钥封装成密文一并提交,控制面存进 nodes.wireguard_private_key_escrow

公钥一致(matched)时也会刷新密文——换了恢复公钥之后,旧密文会被新的替换。


恢复路径

按丢失范围分两种,走的路完全不同。

只丢节点(数据库还在)

不需要托管。 密钥本来就在控制面:重装节点、让 agent 重新注册,materialize 会把原私钥连同其余配置一起下发,对端 peer 无需任何改动。

这是绝大多数情况,也是密钥落库带来的直接好处。

数据库也丢了(需要托管,且需事先启用)

前提是启用过托管、且手上有导出的密文与离线恢复私钥。

  1. 从备份或残留数据里取出该节点的密文(base64 blob)。

  2. 在离线机器上解封:

    bash
    python deploy/tools/dn42-recover/dn42_recover.py recover \
      --recovery-private ./recovery-keys/recovery-private.pem \
      --ciphertext <escrow-blob 或文> \
      --expect-public <该节点已知 WG>   # 可选校验

    --expect-public 校验解出的私钥确实对应预期公钥,防止恢复错节点。

  3. 把还原出的私钥灌回控制面的密钥表(显式轮换路径 set_node_key,或 POST /admin/fleet/migrate-keys 的收养逻辑),再重物化该节点。

    注意方向:私钥要回到控制面,不是回到节点的状态目录——节点侧已经没有密钥文件这个概念了。

没启用托管、数据库又没有可用备份时,只能给节点换一对新密钥并通知全部外部对端更新公钥;内部对端会随 materialize 自动跟随。


安全要点

  • 恢复私钥永不出现在控制面或节点,只在离线 dn42-recover 里短暂使用。
  • dn42_recover.py 只依赖 dn42_common.crypto,与控制面和 agent 的代码路径隔离。
  • 托管密文与私钥明文存在同一个数据库里(分别在 nodesnode_wireguard_keys 两张表)。所以托管防的是「数据库整体丢失」,不防「数据库被读取」——后者要靠库的访问控制。
  • 常规备份策略应当把 node_wireguard_keys 视为最高敏感级别的表。