NixOS 不见光凭据管理综述title
在 NixOS(下称nix)储存凭据有些比较独特的问题:
"下文区分凭据 (secret, credential) 密钥 (key / identity) 加密凭据 (crypted secret)。
"
- nix 的系统配置在重新构建(rebuild) 的时候,为了确保其纯净性1(purity),所有nix file中引用的path 都必须2在 nix store 中
- nix store 是全局可读的(__4)
- nix 配置在部署或求值的时候会将整个配置仓库复制进nix store
- 部分应用运行所需要的凭据不应该被全局可读(如 wireguard / ssh secret key)
- 上述应用的配置必须在nix配置中描述
限制
-
不能将明文密码以字符串字面量写入配置文件
否则在本地全局可读
-
不能将明文密码存入文件,放在配置仓库以外的地方,并在nix配置中使用
builtins.readFile
它会导致impure并且将明文密码写入nix store,与上一条没有区别
我们可以:
-
在nix配置中使用字符串(str)绝对路径,指向配置仓库外存有明文密码的文件
缺点:仓库不包含复现配置的所有信息了,重新部署需要手动复制仓库外的明文凭据。并且绝对路径维护麻烦
-
使用凭据管理方案
缺点:学习成本++
优点:明文凭据不落盘, 普遍解密到不swap的ramfs
本文以两种主流的nixos凭据管理方案 (sops-nix, agenix) 为例,总结一些nix上凭据管理方案的通用规律。
nix 的密码管理工具有个基本的普遍的原理,如上文所述,只是它们把明文凭据加密后存在配置仓库中,在deploy(rebuild)或者开机的时候解密到固定的路径。
为了实现在多个nixosConfigurations 时「一个host的加密凭据不能被另一个host解密」,需要每个设备提供不一样的密钥。有个天然的选择:ssh host key。
凭据管理通常需要有两个密钥对:
-
每个 host 相异的 identity(在有些方案中可能多个host共用一个): per host identity
用来给被部署的host解密(被部署端存有私钥, 即ssh host private key),通常位于
/etc/ssh/ssh_host_id_ed25519_key
-
一个独立的identity: admin identity
部署者(user)使用其加密明文凭据,然后将加密后的凭据储存在 nix store or whatever 里(例如agenix-rekey 就可以直接储存在配置仓库中)。
如果使用了 root on tmpfs 的架构,需要将 ssh host private key 的路径手动写成 persist bindmount source 的绝对路径,如/persist/etc/ssh/ssh_host_id_ed25519_key
, 否则可能遇到timing问题
上文的两个identity都包含私钥和公钥。后文中公钥使用(pub)后缀
关键步骤
常见的流程,用户在本地通过admin identity (pub) 加密并储存凭据,
部署时或部署前(取决于各个工具的实现方式)在部署端或者被部署端使用 admin identity 解密,并将使用host identity (pub) 加密的凭据 在被部署端被 systemd 或者activation script 使用 host identity 解密。
以 sops-nix 为例
如sops-nix使用(gpg或age) admin identity 和 host 的(gpg或age)公钥在本地加密储存凭据, 达到了编辑时使用admin identity,部署时传输上述加密的内容,在对应机器使用host 的(gpg或age)私钥解密
在sops-nix里面per host identity是通过 ssh-keyscan 或者直接读/etc/ssh
获取, 如
$ nix-shell -p ssh-to-age --run 'ssh-keyscan example.com | ssh-to-age'
age1rgffpespcyjn0d8jglk7km9kfrfhdyev6camd3rck6pn8y47ze4sug23v3
$ nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'
age1rgffpespcyjn0d8jglk7km9kfrfhdyev6camd3rck6pn8y47ze4sug23v3
然后通过可选的转换步骤后放在这里 ,无论是gpg还是age都是从 per server的host public key derive出来的。后者用户自己生成 然后放在这里 。
当然另有完全GPG的配置方法,但是过于老旧不赘述,不过是上文per host identity换成GPG密钥,目标机器解密的时候读GPG home(太。。了)
最后的sops配置文件:
keys:
- &server_nosaxa age1rgffpespcyjn0d8jglk7km9kfrfhdyev6camd3rck6pn8y47ze4sug23v3
- &admin_bob age12zlz6lvcdk6eqaewfylg35w0syh58sm7gh53q5vvn7hd7c6nngyseftjxl
creation_rules:
- path_regex: secrets/[^/]+\.(yaml|json|env|ini)$
key_groups:
age:
- *admin_bob
- *server_nosaxa
意味着 secrets/[^/]+\.(yaml|json|env|ini)$
下的路径可以被 server_nosaxa
和 admin_bob
的私钥解密。
而编辑仓库中储存的密钥所使用的(admin identity
)是 age.keyFile
(忘了gpg吧) 所设定的私钥文件。即第二步中生成的密钥
不过我感觉 sops-nix 的有点冗余了。
以agenix为例
考虑如下agenix配置
let
user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH";
user2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI6jSq53F/3hEmSs+oq9L4TwOo1PrDMAgcA1uo1CCV/";
users = [ user1 user2 ];
system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE";
system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1";
systems = [ system1 system2 ];
in
{
"secret1.age".publicKeys = [ user1 system1 ];
"secret2.age".publicKeys = users ++ systems;
}
user 和 system prefix 的公钥就对应上文描述的 admin identity
和 per host identity
。
相比sops-nix我更喜欢agenix。
见 wiki
- [1] 即函数式的纯净性,1.给定相同的输入,总是返回相同的输出。也就是说,函数的输出只取决于输入,不受外部状态的影响。2.没有副作用。 函数不会修改外部的状态,包括全局变量、数据结构或输入参数。
- [2] 可以额外添加
--impure
来继续构建。