Secret Management on NixOS with sops-nix

Michael Stapelberg

在 NixOS 上使用 sops-nix 进行密钥管理

原文由 Michael Stapelberg 发布,订阅该博客

密码和密钥文件之类的密钥在计算机领域无处不在。配置 Linux 系统时,迟早需要在某处填入密码——例如,我将现有的 Linux 网络存储(NAS)配置迁移到 NixOS时,就需要在 NixOS 配置中指定所需的 Samba 密码(或者在 NixOS 之外手动管理)。对于个人电脑来说这样做没问题,但如果目标是共享系统配置(比如放在 Git 仓库里),就需要另一种方案:密钥管理。

什么是密钥管理?

密钥管理系统背后的基本思路是对静态存储的密钥进行加密,也就是说,即使有人克隆了包含 NixOS 系统配置的 Git 仓库,也无法访问(因而也无法部署)其中已加密的密钥。

从概念上讲,我们需要做到:

  1. 对密钥进行加密,使目标系统能够解密。
  2. 对密钥进行加密,使参与该配置协作的其他人也能解密。
  3. 让目标系统在运行时解密密钥。
  4. 告诉我们的软件去哪里读取解密后的密钥。

sops-nix 配置

本文将展示如何使用 sops-nix 实现上述目标。下面先快速介绍一下我们会用到的三个组成部分:

  • sops 是一个以加密形式在 Git 中对密钥进行版本管理的工具。
    • sops 可以在添加或移除授权密钥时轻松地对这些密钥重新加密。
    • sops 非常灵活,可以与大量其他工具/提供方配合使用。
  • sops-nix 提供了将 sops 与 Nix/NixOS 集成的方法
  • 配合 age(1) 使用 sops 后,我们可以直接使用现有的 SSH 私钥(人)或 SSH 主机私钥(机器),而无需另外管理一套密钥文件。

你可能会好奇,为什么我在另一个候选方案 agenix 和 sops-nix 之间选择了后者?当初初看时,sops-nix 的配置说明对我来说更容易理解,而且我希望保留以 sops 的其他方式使用它的可能性,而不仅仅是配合 age。如果你对 agenix 感兴趣,可以看看 Andreas Gohr 关于 agenix 的博文

步骤 1:准备工作

我在一台已安装 Nix 工具并启用了 Nix Flakes 的 Arch Linux 机器上执行了以下操作。其他系统如 Debian 或 Fedora 的安装说明请点击链接查看。

步骤 2:从个人 SSH 密钥派生 age 身份

我不想额外管理一个密钥文件,因此会使用 ssh-to-age 从已妥善备份的 SSH 私钥文件中派生一个密钥:

midna % mkdir -p $HOME/.config/sops/age/
midna % read -s SSH_TO_AGE_PASSPHRASE; export SSH_TO_AGE_PASSPHRASE
midna % nix run nixpkgs#ssh-to-age -- \
  -private-key \
  -i $HOME/.ssh/id_ed25519 \
  -o $HOME/.config/sops/age/keys.txt

SSH_TO_AGE_PASSPHRASE 选项在 ssh-to-age README 中有说明。)

要查看该 age 身份(私钥)对应的 age 接收方(公钥),我执行了:

midna % nix shell nixpkgs#age
midna 2 % age-keygen -y $HOME/.config/sops/age/keys.txt
age10e9tt2qwq90y5hvl35dau0sm5cm4qvegtw2a70v7sz5fy99de42s9d5nkf

步骤 3:为远程机器获取 age 接收方

类似地,我会从远程系统的 SSH 主机密钥派生一个 age 接收方:

batchn % cat /etc/ssh/ssh_host_ed25519_key.pub | nix run nixpkgs#ssh-to-age
age1wnwfnrqhewjh39pmtyc8zhqw606znskt4h5p9s3pve4apd67gapqj6tr0k

步骤 4:为 Git 仓库配置 sops

在我的 Git 仓库(nix-configs)中,每个 NixOS 系统对应一个子目录,例如 tree(1) 的输出如下:

├── batchn
│   ├── configuration.nix
│   ├── disk-config.nix
│   ├── flake.lock
│   ├── flake.nix
│   ├── hardware-configuration.nix
│   ├── Makefile
│   ├── secrets
│   │   └── example.yaml
├── wiki
│   ├── configuration.nix
│   ├── disk-config.nix
│   ├── flake.lock
│   ├── flake.nix
│   ├── hardware-configuration.nix
│   ├── Makefile
…

在 Git 仓库的根目录(与 batchn 目录同级)下,我创建了如下的 .sops.yaml 文件:

keys:
  - &admin_michael age10e9tt2qwq90y5hvl35dau0sm5cm4qvegtw2a70v7sz5fy99de42s9d5nkf
  - &server_batchn age1wnwfnrqhewjh39pmtyc8zhqw606znskt4h5p9s3pve4apd67gapqj6tr0k
# …more server keys go here…
creation_rules:
  - path_regex: batchn/secrets/[^/]+\.(yaml|json|env|ini)$
    key_groups:
    - age:
      - *admin_michael
      - *server_batchn

我管理的系统越多,需要配置的 keyscreation_rules 就越多。

创建规则会告诉 sops 在加密文件时使用哪些密钥。在我的配置中,每个系统通常只使用单个文件,但如果我想就系统的某一方面与他人协作,也可以考虑将部分密钥拆分到单独的文件中。

步骤 5:使用 sops 管理密钥

既然已经告诉 sops 要为哪些接收方加密,我们就可以通过运行以下命令,在已配置好的编辑器中解密并编辑 secrets/example.yaml

midna ~/nix-configs/batchn % nix run nixpkgs#sops secrets/example.yaml

最简单的密钥文件只包含一个键,例如:

api-key: hello world :)

保存并退出编辑器后,sops 会更新已加密的 secrets/example.yaml 文件。

步骤 6:在 NixOS 中配置 sops

接下来,我们需要在 NixOS 中引用这个已加密的文件,并启用 sops-nix 集成,以便在系统上提供解密后的密钥。

flake.nix 中,我在 inputs 部分添加了 sops-nix,并引入了 NixOS 模块。我展示完整的 diff,是因为这些行的位置与内容本身同样重要:

--- c/batchn/flake.nix
+++ i/batchn/flake.nix
@@ -1,85 +1,93 @@
 {
   inputs = {
     nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";

     disko.url = "github:nix-community/disko";
     # Use the same version as nixpkgs
     disko.inputs.nixpkgs.follows = "nixpkgs";

     stapelbergnix.url = "github:stapelberg/nix";

     zkjnastools.url = "github:stapelberg/zkj-nas-tools";

+    sops-nix = {
+      url = "github:Mic92/sops-nix";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
+
   };

   outputs =
     {
       nixpkgs,
       disko,
       stapelbergnix,
       zkjnastools,
+      sops-nix,
       ...
     }:
     let
       system = "x86_64-linux";
       pkgs = import nixpkgs {
         inherit system;
         config.allowUnfree = false;
       };
     in
     {
       nixosConfigurations.batchn = nixpkgs.lib.nixosSystem {
         inherit system;
         inherit pkgs;
         modules = [
           disko.nixosModules.disko
           ./configuration.nix
           stapelbergnix.lib.userSettings
           # Use systemd for network configuration
           stapelbergnix.lib.systemdNetwork
           # Use systemd-boot as bootloader
           stapelbergnix.lib.systemdBoot
           # Run prometheus node exporter in tailnet
           stapelbergnix.lib.prometheusNode
           zkjnastools.nixosModules.zkjbackup
+          sops-nix.nixosModules.sops
         ];
       };
       formatter.${system} = pkgs.nixfmt-tree;
     };
 }

然后,在 configuration.nix 中,我们告诉 sops-nix 使用 SSH 主机密钥作为身份标识,指定 sops 在何处查找密钥,以及 sops-nix 应该在远程系统上具体落地哪些密钥:

  sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
  sops.defaultSopsFile = ./secrets/example.yaml;
  sops.secrets."api-key" = { };

部署完成后,我们就可以在运行中的系统上访问该密钥:

batchn ~ % sudo cat /run/secrets/api-key
hello world :) %
batchn ~ %

当然,即使重启机器,无需重新部署,密钥也依然可用:

batchn ~ % uptime
 22:09:23  up   0:00,  1 user,  load average: 0,32, 0,08, 0,03
batchn ~ % sudo cat /run/secrets/api-key
hello world :) %
batchn ~ %

使用示例

既然密钥已经以文件的形式存储在 /run/secrets 下,我们该如何使用它们呢?

下面几节将展示几种常见的使用方式。

使用示例:命令行参数(ExecStart 包装)

假设你在 NixOS 上以 systemd 服务的形式部署了一个自定义的 Go 服务器,配置如下,而你想开始对通过 -securecookie_hash_key-securecookie_block_key 命令行参数传入的明文密钥进行管理:

{
  users.groups.fortuneserver = { };
  users.users.fortuneserver = {
    isSystemUser = true;
    group = "fortuneserver";
  };

  systemd.services.fortuneserver = {
    description = "fortuneserver";
    documentation = [ "https://michael.stapelberg.ch" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      User = "fortuneserver";
      Group = "fortuneserver";

      ExecStart = ''
        "${pkgs.fortuneserver}/bin/fortuneserver" \
          -securecookie_hash_key="some-secret-key" \
          -securecookie_block_key="a-different-secret-key"
      '';
    };
  };
}

对应的 sops 密钥如下:

fortuneserver:
    securecookie_hash_key: some-secret-key
    securecookie_block_key: a-different-secret-key

……我们需要调整 NixOS 配置,使其在运行时读取这些密钥文件。由于 ExecStart 指令由 systemd 直接解析而不会经过 shell,我们使用 writeShellScript 辅助函数,然后通过 cat 来读取文件:

{
  sops.secrets."fortuneserver/securecookie_hash_key" = {
    owner = "fortuneserver";
    restartUnits = [ "fortuneserver.service" ];
  };
  sops.secrets."fortuneserver/securecookie_block_key" = {
    owner = "fortuneserver";
    restartUnits = [ "fortuneserver.service" ];
  };

  users.groups.fortuneserver = { };
  users.users.fortuneserver = {
    isSystemUser = true;
    group = "fortuneserver";
  };

  systemd.services.fortuneserver = {
    description = "fortuneserver";
    documentation = [ "https://michael.stapelberg.ch" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      User = "fortuneserver";
      Group = "fortuneserver";

      ExecStart = pkgs.writeShellScript "fortuneserver-execstart" ''
        "${pkgs.fortuneserver}/bin/fortuneserver" \
          -securecookie_hash_key="$(cat /run/secrets/fortuneserver/securecookie_hash_key)" \
          -securecookie_block_key="$(cat /run/secrets/fortuneserver/securecookie_block_key)"
      '';
    };
  };
}

使用示例:环境变量文件

如果相关服务不是通过命令行参数,而是通过环境变量来配置密钥,该怎么办?我们可以将一个环境变量文件放入由 sops 管理的密钥中:

translate-fe:
    env: |
        DEEPL_AUTH_KEY=my-deepl-key

……然后让 systemd 从该密钥文件中加载这些环境变量:

{
  sops.secrets."translate-fe/env" = {
    owner = "translatefe";
    restartUnits = [ "translate-fe.service" ];
  };

  systemd.services.translate-fe = {
    documentation = [ "https://michael.stapelberg.ch" ];
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      User = "translatefe";

      EnvironmentFile = [ config.sops.secrets."translate-fe/env".path ];

      ExecStart = "${translatefeExecstart}/bin/translate-fe";
    };
  };
}

如果你配置的是 NixOS 模块(而非声明自定义服务),对应的选项不一定叫 EnvironmentFile。例如,对于 oauth2-proxy 服务,你需要配置 services.oauth2-proxy.keyFile 选项

  services.oauth2-proxy = {
    keyFile = config.sops.secrets."oauth2-proxy/env".path;
    enable = true;
    # …
  };

使用示例:systemd 凭证

在前面的示例中,我们将每个密钥的 owner 配置为服务运行所用的用户账号。但如果服务使用了 systemd 的 DynamicUser 特性,根本没有这样的用户账号,该怎么办?

我们可以使用 systemd 的 LoadCredential 特性!例如,我就是这样为 Prometheus Alertmanager 提供 SMTP 密码的:

{
  sops.secrets."alertmanager/smtp_pw" = {
    restartUnits = [ "alertmanager.service" ];
  };

  systemd.services.alertmanager.serviceConfig.LoadCredential = [
    "smtp_pw:${config.sops.secrets."alertmanager/smtp_pw".path}"
  ];

  services.prometheus.alertmanager = {
    enable = true;

    configuration = {
      global = {
        smtp_smarthost = "smtp.gmail.com:587";
        smtp_from = "[email protected]";
        smtp_auth_username = "[email protected]";
        smtp_auth_password_file = "/run/credentials/alertmanager.service/smtp_pw";
      };

      # …remaining config goes here…
    };
  };
}

使用示例:Samba 用户/密码

在我的博文《将 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS》中,我介绍了如何通过 ExecStartPre Shell 脚本来配置 Samba 用户和密码(来源于由 sops 管理的密钥),其方式与前面介绍的技术非常相似。

总结

在我看来,将密钥作为单独加密的文件放在配置仓库中是非常合理的做法!

依我看,age 能够直接使用 SSH 密钥的特性让整个配置变得非常方便。为目标系统的 SSH 主机密钥加密密钥的做法非常优雅。

希望上面的示例足以帮助你在 NixOS 中高效地配置密钥!

本文章由 muse-spark-1.2-contributor 进行翻译

评论