Secret Management on NixOS with sops-nix

Michael Stapelberg

sops-nixでNixOSの秘密情報を管理する

パスワードや暗号鍵ファイルのような秘密情報は、コンピューティングのあらゆる場所に存在します。Linuxシステムを設定していると、遅かれ早かれ、どこかにパスワードを書かなければならない場面が出てきます。たとえば、既存のLinux Network Storage(NAS)環境をNixOSに移行したときには、希望するSambaのパスワードをNixOSの設定に記述する必要がありました(あるいは、NixOSの外で手動管理する必要がありました)。個人用コンピューターであればこれでも問題ありません。しかし、システム設定をGitリポジトリなどで共有したい場合は、別の解決策が必要です。それが秘密情報管理(Secret Management)です。

秘密情報管理とは?

秘密情報管理システムの基本的な考え方は、保存時の秘密情報を暗号化しておくことです。つまり、NixOSのシステム設定を含むGitリポジトリを誰かがクローンしても、暗号化された秘密情報にアクセスできず、したがってそれをデプロイすることもできません。

概念的には、次のことが必要です。

  1. 対象システムが復号できるように秘密情報を暗号化する。
  2. この設定を扱うほかの人も復号できるように秘密情報を暗号化する。
  3. 実行時に対象システムが秘密情報を復号する。
  4. 復号された秘密情報にソフトウェアがアクセスする場所を指定する。

sops-nixのセットアップ

この記事では、sops-nixを使って上記を実現する方法を説明します。ここでは、使用する3つの構成要素を簡単に紹介します。

  • sopsは、秘密情報を暗号化した形でGit管理するためのツールです。
    • sopsを使うと、認証済みキーを追加・削除したときに秘密情報を簡単に再暗号化できます。
    • sopsは非常に柔軟で、ほかにも多数のツールやプロバイダーと連携できます。
  • sops-nixは、sopsをNix/NixOSに統合する手段を提供します。
  • sopsをage(1)と組み合わせることで、別個のキーファイル一式を管理する代わりに、既存のSSH秘密鍵(人間の場合)またはSSHホスト秘密鍵(マシンの場合)を使えます。

もう一つの有力候補であるagenixではなく、なぜsops-nixを選んだのか疑問に思うかもしれません。最初に調べたとき、sops-nixのセットアップ手順のほうが自分には分かりやすく感じました。また、ageだけでなく、別の方法でもsopsを使える選択肢を残しておきたかったのです。agenixに興味がある方は、Andreas Gohrによるagenixの記事をご覧ください。

ステップ1:準備

以下の手順は、Nixツールをインストールし、Nix Flakesを有効にしたArch Linuxマシンで実行しました。DebianやFedoraなど、ほかのシステムでの手順もリンク先にあります。

ステップ2:個人用SSHキーからage identityを取得する

別のキーファイルを管理したくないので、すでにバックアップを含めてしっかり管理しているSSH秘密鍵ファイルから、ssh-to-ageを使ってキーを導出します。

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 identity(秘密鍵)のage recipient(公開鍵)を表示するには、次のようにしました。

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

ステップ3:リモートマシン用のage recipientを取得する

同様に、リモートシステムのSSHホストキーからage recipientを導出します。

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 ruleは、ファイルの暗号化に使うキーをsopsに指定します。私の構成では通常、システムごとにファイルを一つだけ使います。ただ、システムの一部分だけを誰かと共同で作業したい場合には、秘密情報の一部を別ファイルに分けることも考えられます。

ステップ5:sopsで秘密情報を管理する

sopsに暗号化対象のrecipientを指定できたので、次のコマンドを実行すれば、設定済みのエディターで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.nixinputsセクションにsops-nixを追加し、NixOSモジュールも追加しました。行をどこに置くかは、そこに何を書くかと同じくらい重要なので、差分全体を示します。

--- 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で、SSHホストキーをidentityとして使うこと、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が解釈し、シェルには渡されません。そのため、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 credentials

これまでの例では、各秘密情報の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のユーザー/パスワード

ブログ記事「CoreOS/Flatcar LinuxからNixOSへNASを移行する」では、sops管理の秘密情報からSambaのユーザーとパスワードを設定する方法を、ExecStartPreのシェルスクリプトを使って説明しています(ここまでに説明した手法とよく似ています)。

まとめ

設定リポジトリ内で秘密情報を別々に暗号化したファイルとして管理する方法は、私には理にかなっていると思えます。

ageがSSHキーを扱えるおかげで、とても便利な構成になります。送り先システムのSSHホストキー用に秘密情報を暗号化するという仕組みは、とても洗練されていると感じます。

ここで紹介した例が、NixOSで秘密情報を効率よく設定する助けになれば幸いです。

原文は Michael Stapelberg により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。