Secret Management on NixOS with sops-nix

Michael Stapelberg

sops-nix로 NixOS에서 시크릿 관리하기

원문은 Michael Stapelberg님이 에 게재했습니다. 이 블로그 구독하기

컴퓨팅 환경에서는 비밀번호나 암호화 키 파일 같은 시크릿이 어디에나 존재합니다. Linux 시스템을 설정하다 보면 언젠가는 어딘가에 비밀번호를 넣어야 합니다 — 예를 들어 필자가 기존 Linux Network Storage(NAS) 환경을 NixOS로 이전했을 때 NixOS 설정에 원하는 Samba 비밀번호를 지정해야 했습니다(혹은 NixOS 외부에서 수동으로 관리해야 했습니다). 개인용 컴퓨터라면 이렇게 해도 괜찮지만, 시스템 설정을 공유하는 것이 목표라면(예를 들어 Git 저장소에서) 다른 해결책이 필요합니다: 시크릿 관리(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 호스트 개인 키(머신)를 사용할 수 있습니다.

다른 대안인 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 시스템별로 하나의 하위 디렉터리가 있으며, 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에 어떤 수신자를 대상으로 암호화할지 알려줬으니, 다음을 실행해 설정된 에디터에서 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가 직접 해석하고 셸을 거치지 않으므로, 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”에서 sops로 관리되는 시크릿으로부터 Samba 사용자와 비밀번호를 ExecStartPre 셸 스크립트로 설정하는 방법을 설명합니다(이미 설명한 기법과 매우 유사합니다).

결론

설정 저장소에서 시크릿을 별도로 암호화된 파일로 관리하는 방식은 제게는 합리적으로 느껴집니다!

제 생각에는 age가 SSH 키와 함께 동작할 수 있다는 점이 정말 편리한 설정을 만들어 줍니다. 대상 시스템의 SSH 호스트 키를 대상으로 시크릿을 암호화하는 방식이 매우 우아하게 느껴집니다.

위의 예시들이 NixOS에서 시크릿을 효율적으로 설정하는 데 충분했기를 바랍니다!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글