Secret Management on NixOS with sops-nix

Michael Stapelberg

sops-nixによるNixOSのシークレット管理

原文は Michael Stapelberg により に公開されました。 このブログを購読する

パスワードや暗号鍵ファイルのようなシークレットは、コンピューティングのあらゆる場面に存在します。Linuxシステムを設定する際には、遅かれ早かれどこかにパスワードを置く必要が出てきます。たとえば私が既存のLinux Network Storage(NAS)環境をNixOSに移行した際には、NixOSの設定内でSambaのパスワードを指定する必要がありました(あるいはNixOSの外で手動で管理する方法もあります)。個人のコンピューターであればそれでも問題ありませんが、システム設定を(たとえばGitリポジトリで)共有することが目的の場合、別の解決策が必要になります。それがシークレット管理です。

シークレット管理とは?

シークレット管理システムの基本的な考え方は、シークレットを保存された状態で暗号化しておくことです。つまり、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アイデンティティを取得する

余計な鍵ファイルを管理したくないので、すでに大切にバックアップしている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アイデンティティ(秘密鍵)に対応する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システムごとに1つのサブディレクトリを用意しています。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に伝えるものです。私の環境では通常、システムごとに1つのファイルだけを使っていますが、システムのある一部について誰かと共同作業したい場合には、シークレットの一部を別ファイルに分割することも考えられます。

ステップ5. sopsでシークレットを管理する

sopsにどのレシピエント向けに暗号化するかを伝えたので、次のコマンドを実行することで、設定済みのエディタでsecrets/example.yamlを復号して編集できます。

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

最もシンプルな鍵ファイルは、たとえば次のように1つの鍵だけを含みます。

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で、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によって解釈され、シェルを経由しないため、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」では、Sambaのユーザーとパスワード(sopsで管理されたシークレットから)をExecStartPreのシェルスクリプトで設定する方法を説明しています(これはすでに説明した手法とよく似ています)。

まとめ

設定リポジトリ内でシークレットを個別に暗号化されたファイルとして管理するという考え方は、私にはとても理にかなっていると感じます。

個人的には、ageがSSH鍵を扱えることで非常に手軽なセットアップが実現できると思います。宛先システムのSSHホスト鍵向けにシークレットを暗号化するというのは、とてもエレガントなやり方だと感じます。

上記の例が、NixOSでシークレットを効率的に設定するうえで十分な参考になれば幸いです。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント