NixOS 不见光凭据管理综述title

2024-09-29date
Most NixOS Secret Management Tools In Common
description

在 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 就可以直接储存在配置仓库中)。

Information

如果使用了 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_nosaxaadmin_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 identityper host identity

相比sops-nix我更喜欢agenix。

wiki

  • [1] 即函数式的纯净性,1.给定相同的输入,总是返回相同的输出。也就是说,函数的输出只取决于输入,不受外部状态的影响。2.没有副作用。 函数不会修改外部的状态,包括全局变量、数据结构或输入参数。
  • [2] 可以额外添加 --impure 来继续构建。
©2018-2025 Secirian | CC BY-SA 4.0