↓ 跳过正文

NixOS(九):使用sops-nix管理机密

·4653 字·10 分钟
锦李本鲤
作者
锦李本鲤
光锥之内,皆为命运。
NixOS系列 - 这篇文章属于一个选集。
§ 9: 本文

缘起
#

在上一篇中,我们介绍了用Home Manager管理个人配置的方法。普通设置可以写进Nix文件,但密码、API令牌和私钥需要另外处理。即使配置仓库是私有的,也不适合把这些内容直接写进配置。

原因是,Nix在构建时会将很多配置和源文件放进store。配置能够重复构建,并不意味着构建结果中的内容只有自己能看到。因此,这里介绍配置与机密的区别,以及使用sops-nix管理机密的方法。

下面使用虚构主机laptop和一个只检查文件能否读取的服务。示例中的密码没有实际用途,不使用现有服务的配置或真实密钥。

Nix store与机密
#

Nix store中的文件通常对本机其他用户可读。把密码放进environment.etc.*.text、Home Manager的home.file.*.text,或者构建脚本生成的文件中,都可能让密码出现在store里。不能靠最后安装到目标位置时设置0600,来消除前面已经产生的副本。

另一个容易忽略的地方是flake源文件。本地Git flake会读取被Git跟踪的文件,所以明文即使没有被模块引用,也不应该放进作为flake输入的仓库。可以参考Nix的flake输入说明。

因此,需要区分下面几种内容:

内容保存位置
服务名称、配置结构、机密文件路径Nix配置
加密后的机密文件配置仓库,是否公开还需检查元数据
解密用的私钥仓库和store以外的受保护位置
服务使用的明文运行时文件或服务的凭据目录

这里的“路径”只是告诉程序去哪里读取文件,和把文件内容读进Nix表达式不同。对运行时机密使用builtins.readFile,会把明文带入求值过程,也会破坏构建机器不需要解密密钥的安排。

运行时文件仍然有访问权限问题。root、获得服务权限的程序和有权限的备份工具,可能读取其中的内容。加密保存解决的是仓库和构建阶段的暴露问题,不能替代系统账户、服务权限和日志管理。

sops-nix简介
#

这里使用三个工具:

  • age:生成用于加密和解密的密钥;
  • SOPS:编辑加密的YAML等文件;
  • sops-nix:在NixOS中声明机密的来源、目标位置和访问权限,并在目标系统运行时解密。

SOPS可以使用不同的加密方式,这里只介绍age。age的公钥也称为recipient,可以用来加密;对应私钥用于解密,不应加入Git。相关用法可以参看age文档。

下面采用NixOS模块方式,与上一篇的Home Manager模块不是同一个集成层。示例沿用NixOS 26.05,并使用支持useSystemdActivation的sops-nix版本;具体依赖由自己的flake.lock记录。

配置sops-nix
#

添加flake输入
#

在已有flake.nix中加入:

1
2
inputs.sops-nix.url = "github:Mic92/sops-nix";
inputs.sops-nix.inputs.nixpkgs.follows = "nixpkgs";

让outputs接收sops-nix输入,再在已有主机的模块列表里加入对应模块。例如,下面是输出属性集中的一个片段:

1
2
3
4
5
6
7
8
nixosConfigurations.laptop = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [
    ./hosts/laptop/configuration.nix
    sops-nix.nixosModules.sops
    ./modules/secrets-demo.nix
  ];
};

原有模块要保留,不需要为了这个例子重写整个flake。输入使用的分支会继续更新,而本地锁文件记录具体版本;首次添加后要检查flake.lock,以后按计划更新。

创建演示密钥
#

下面假设编辑配置和运行演示服务的是同一台电脑。在配置仓库根目录打开一个带工具的Shell:

1
nix shell nixpkgs#sops nixpkgs#age

在这个Shell中创建一份专门用于演示的密钥:

1
2
3
4
5
umask 077
mkdir -p "$HOME/.config/sops/age"
age-keygen -o "$HOME/.config/sops/age/nixos-demo.txt"
export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/nixos-demo.txt"
age-keygen -y "$SOPS_AGE_KEY_FILE"

最后一条只输出公钥。私钥保存在仓库之外;如果这个文件已经存在,先检查用途,不要覆盖它。SOPS_AGE_KEY_FILE让后续SOPS命令找到这份演示私钥,换一个Shell后需要重新设置。

实际管理多台电脑时,通常应分别使用管理者的编辑密钥、各台电脑的解密密钥,以及受保护的恢复密钥。这里共用一份演示密钥,是为了先把本机流程说明白,不建议把同一份私钥复制到所有电脑。

创建加密文件
#

在仓库根目录创建.sops.yaml:

1
2
3
creation_rules:
  - path_regex: ^secrets/demo\.yaml$
    age: "<AGE_RECIPIENT>"

把<AGE_RECIPIENT>替换成刚才输出的公钥。这是需要替换的占位符,不能直接用于加密。然后创建机密目录并打开编辑器:

1
2
mkdir -p secrets
sops edit secrets/demo.yaml

在SOPS打开的编辑器里写入:

1
2
demo:
  password: "example-password-not-for-real-use"

保存并退出后,磁盘上的secrets/demo.yaml应是加密文件,而不是上面的明文。再次修改时继续用sops edit。不要先把真实密码写入普通YAML,再指望提交前一定能记得加密;编辑器的交换文件、备份和临时文件也需要考虑。

SOPS通常保留YAML键名和加密元数据,所以“加密文件”不等于“可以随意公开的文件”。服务名称、收件人列表和备注仍可能透露配置结构。关于文件编辑和配置规则,可以参看SOPS使用文档。

提供目标系统的私钥
#

在这台演示电脑上,把演示私钥安装到root管理的运行位置:

1
2
3
sudo install -d -m 0700 /var/lib/sops-nix
sudo test ! -e /var/lib/sops-nix/key.txt && \
  sudo install -m 0600 "$SOPS_AGE_KEY_FILE" /var/lib/sops-nix/key.txt

这两条命令用于首次准备演示环境。已有key.txt时不要覆盖,它可能正被其他配置使用。真实的远程电脑需要通过可信的方式准备它自己的私钥,构建结果不会自动包含这份私钥。

/var/lib/sops-nix/key.txt需要持久保存。只保存配置仓库,重装后并不能凭空恢复解密能力。

声明机密与演示服务
#

创建modules/secrets-demo.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{ config, pkgs, ... }:
{
  sops = {
    defaultSopsFile = ../secrets/demo.yaml;
    age.keyFile = "/var/lib/sops-nix/key.txt";
    age.sshKeyPaths = [ ];
    gnupg.sshKeyPaths = [ ];
    useSystemdActivation = true;

    secrets."demo/password" = {
      owner = "root";
      group = "root";
      mode = "0400";
    };
  };

  systemd.services.secret-demo = {
    description = "Check access to a demonstration credential";
    wantedBy = [ "multi-user.target" ];
    requires = [ "sops-install-secrets.service" ];
    after = [ "sops-install-secrets.service" ];
    serviceConfig = {
      Type = "oneshot";
      DynamicUser = true;
      LoadCredential = [
        "password:${config.sops.secrets."demo/password".path}"
      ];
    };
    script = ''
      test -s "$CREDENTIALS_DIRECTORY/password"
      ${pkgs.coreutils}/bin/head -c 1 \
        "$CREDENTIALS_DIRECTORY/password" > /dev/null
    '';
  };
}

defaultSopsFile相对于这个模块的位置,指向仓库中的加密文件。机密名称demo/password对应YAML中的嵌套键,默认目标是/run/secrets/demo/password。

这里关闭SSH主机密钥的自动导入,只使用准备好的age私钥。keyFile写成字符串,表示运行时路径,不是把私钥作为Nix源文件复制进去。

例子明确启用了useSystemdActivation,所以服务依赖的是sops-install-secrets.service。旧配置如果使用激活脚本解密,不能直接照搬这个服务依赖;Home Manager的用户服务也有另外的启动方式。相关选项可以参看sops-nix模块源码。

演示服务使用systemd的LoadCredential。systemd读取root拥有的机密文件,并向服务提供自己的凭据目录;服务通过CREDENTIALS_DIRECTORY读取内容,因此不用为了这个检查把原文件开放给普通用户。它只检查非空并尝试读取一个字节,不输出密码。凭据传递方法可以参看NixOS的systemd说明。

实际服务应优先使用它支持的passwordFile、credentialsFile等选项,或对应的凭据接口。不要再用Nix读取运行时文件,拼回一个store中的配置。服务需要完整配置文件时,sops-nix也提供运行时模板,可以在需要时单独了解。

构建与验证
#

将配置与加密文件加入Git索引,再构建:

1
2
3
4
git add .sops.yaml secrets/demo.yaml modules/secrets-demo.nix flake.nix
git diff --cached --check
nix flake check --no-build --show-trace
nixos-rebuild build --flake .#laptop

提交前检查暂存的YAML确实是SOPS加密格式,并检查新产生的flake.lock,将它加入索引一起记录。不要把私钥加入索引。构建成功只说明能生成系统,目标机的私钥是否正确、服务是否能读取文件,还要在运行时验证。

按第五篇的流程确认准备应用后,运行:

1
2
3
4
sudo nixos-rebuild switch --flake .#laptop
sudo systemctl start secret-demo.service
systemctl show secret-demo.service -p Result -p ExecMainStatus
sudo stat -Lc '%a %U %G' /run/secrets/demo/password

第二条让演示服务执行检查;如果重建时已经运行过,会再执行一次。正常情况下可以看到Result=success、ExecMainStatus=0,以及原机密文件的400 root root权限。它是一次性服务,成功退出后显示inactive并不表示失败。

这里不需要cat机密内容。真正的服务还应验证实际功能,例如使用凭据完成一次授权请求,同时检查日志是否意外记录了密码。

问题解决
#

解密失败
#

先区分加密文件不存在、YAML键名不匹配和私钥不能解密这几种情况。新文件是否加入Git、模块路径是否正确,可以在构建前检查;私钥和recipient是否匹配,要在目标电脑检查。

这个例子的解密服务日志可以这样查看:

1
2
systemctl status sops-install-secrets.service --no-pager
sudo journalctl -u sops-install-secrets.service -b --no-pager

恢复或复制日志时,先查看内容再决定是否分享。不要为了排错把私钥、解密后的YAML或完整环境变量输出到聊天中。

服务没有使用新密码
#

机密文件更新不代表已经运行的程序重新读取了它。systemd提供的凭据也在服务启动时加载。根据实际服务的使用方式,可能需要重启、重载,或者更新服务端保存的密码。

sops-nix的机密选项可以声明restartUnits或reloadUnits,但应先确认相应服务支持哪种方式。对数据库等服务,外部密码文件变化也不一定会自动修改数据库中的账户密码。

用户密码与提前解密
#

NixOS用户账户的密码可能要在创建用户前提供,和普通服务机密的顺序不同。sops-nix的neededForUsers用于这种提前解密场景;配合hashedPasswordFile时,文件内容应是密码哈希。提前解密时还不能依赖尚未创建的用户来设置所有者,不要直接照搬上面的普通服务例子。

密钥更新与恢复
#

添加一台电脑时,可以将它的公钥加入对应规则,使用仍有解密权限的管理者密钥运行:

1
sops updatekeys secrets/demo.yaml

这会根据.sops.yaml更新现有文件的收件人。只修改规则不会自动更新之前加密的文件,提交并部署前还需要用新增的解密身份检查结果。

SOPS还可以更换文件的数据加密密钥:

1
sops rotate -i secrets/demo.yaml

updatekeys与rotate处理的是加密文件,和在服务端更换API令牌或密码是不同操作。相关区别可以参看SOPS密钥管理文档。如果密钥或机密已经泄露,移除一个recipient不能收回旧Git版本和旧密文,更不能让对方忘掉已经知道的密码。需要撤销实际服务端的旧机密。保存新的密码或令牌之前,先移除泄露的recipient,执行updatekeys和rotate,再录入新值并更新使用它的服务。

恢复方面,应另外保存受保护的恢复身份,并确认它能解密必要的文件。只备份密文、却丢失所有私钥,通常无法恢复内容;只有机器上的一份私钥,也容易在磁盘损坏或重装时一起丢失。可以先在独立的临时环境里用无实际用途的机密练习恢复,不必等故障发生后才检查。

例如,在只提供恢复身份的独立环境中,可以检查演示文件是否能解密,丢弃输出以避免把内容留在终端:

1
2
SOPS_AGE_KEY_FILE=/path/to/recovery-key.txt \
  sops decrypt secrets/demo.yaml > /dev/null

这里需要替换恢复私钥路径,并提前将它对应的公钥加入规则、执行updatekeys。确认退出状态为零,并检查测试环境没有其他可用解密身份,避免实际上使用了日常密钥。

系统回滚还可能重新部署旧密文,但已经撤销的令牌不会因此重新有效。恢复系统配置、恢复解密能力和恢复服务账户,需要分别安排。

推荐的使用方法
#

我建议先用演示机密确认加密、构建、解密和读取的完整流程,再迁移一个实际服务。每个服务只取得自己需要的机密,多台电脑分别配置解密身份,恢复密钥另外保存。

让AI协助时,可以提供选项结构、占位符和已经检查过的错误信息,要求它说明明文最终出现在哪里,以及谁能读取。录入真实机密和操作私钥时,尽量在自己控制的终端或工具中完成,分享前检查文件、截图和日志。

下一篇将介绍系统状态、备份和恢复,包括哪些内容不能靠重新构建NixOS恢复,以及怎样检查备份确实能用。

NixOS系列 - 这篇文章属于一个选集。
§ 9: 本文