Secret Management on NixOS with sops-nix

Michael Stapelberg

在 NixOS 上使用 sops-nix 進行機密管理

在運算環境中,密碼與各種機密(例如加密金鑰檔案)無處不在。在設定 Linux 系統時,遲早都得在某處填入密碼——例如,當我將現有的 Linux 網路儲存空間(NAS)設定遷移至 NixOS時,就必須在 NixOS 設定中指定所需的 Samba 密碼(或在 NixOS 之外手動管理)。對於個人電腦而言,這樣做並無不可,但若目標是共享系統設定(例如放在 Git 儲存庫中),就需要不同的解決方案:Secret Management(機密管理)。

什麼是 Secret Management?

Secret Management 系統背後的基本概念是在靜態時將機密加密,也就是說,即使有人複製了包含你 NixOS 系統設定的 Git 儲存庫,也無法存取(因此也無法部署)這些已加密的機密。

從概念上來說,我們需要做到:

  1. 將機密加密,讓目標系統能夠解密。
  2. 將機密加密,讓其他協作此設定的人也能解密。
  3. 讓目標系統在執行時期解密機密。
  4. 告知我們的軟體該去哪裡存取已解密的機密。

sops-nix 設定

在本文中,我將示範如何使用 sops-nix 達成上述目標。以下是我們將會用到的三個主要組成部分的快速概覽:

  • sops 是一款用於在 Git 中對機密進行版本控管的工具,機密會以加密形式儲存。
    • sops 讓你在新增或移除授權金鑰時,輕鬆地重新加密這些機密。
    • sops 非常靈活,能與眾多其他工具/供應商搭配使用。
  • sops-nix 提供了將 sops 與 Nix/NixOS 整合的方法
  • 將 sops 與 age(1) 搭配使用,可讓我們直接使用現有的 SSH 私鑰(用於人員)或 SSH 主機私鑰(用於機器),而無需另外管理一套金鑰檔案。

你可能會好奇,為什麼我選擇 sops-nix 而非另一個競爭方案 agenix?我初次研究時,覺得 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

建立規則(creation 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 上將一個自訂的 Go 伺服器作為 systemd 服務部署如下,並且希望開始管理透過 -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 使用者/密碼

在我的部落格文章「Migrating my NAS from CoreOS/Flatcar Linux to NixOS(《將我的 NAS 從 CoreOS/Flatcar Linux 遷移至 NixOS》)」中,我說明了如何透過 ExecStartPre shell 指令碼(與前面已說明的手法非常相似)來設定 Samba 使用者與密碼(來自 sops 管理的機密)。

結論

對我而言,將機密作為獨立加密的檔案放在設定儲存庫中是非常合理的做法!

我認為,age 能夠與 SSH 金鑰搭配運作,讓整個設定變得非常方便。為目標系統的 SSH 主機金鑰加密機密,感覺非常優雅。

希望上述範例足以協助你在 NixOS 中有效率地設定機密!

原文由 Michael Stapelberg 發布

本文章由 muse-spark-1.2-contributor 進行翻譯