Secret Management on NixOS with sops-nix

Michael Stapelberg

sops-nix로 하는 NixOS 시크릿 관리

컴퓨팅에서는 비밀번호나 암호화 키 파일 같은 시크릿이 어디에나 존재합니다. Linux 시스템을 설정하다 보면 언젠가는 비밀번호를 어딘가에 넣어야 할 때가 옵니다 — 예를 들어 제가 기존 Linux NAS(Network Storage) 환경을 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와 통합하는 방법을 제공합니다
  • age(1)과 함께 sops를 사용하면 별도의 키 파일 세트를 관리하는 대신 기존 SSH 개인 키(사람)나 SSH 호스트 개인 키(머신)를 그대로 사용할 수 있습니다.

sops-nix와 또 다른 대안인 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 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.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 사용자/비밀번호

제 블로그 글 “Migrating my NAS from CoreOS/Flatcar Linux to NixOS”에서는 sops로 관리되는 시크릿으로부터 Samba 사용자와 비밀번호를 ExecStartPre 셸 스크립트로 설정하는 방법을 설명합니다(앞서 설명한 방식과 매우 유사합니다).

결론

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

개인적으로는 age가 SSH 키와 함께 동작한다는 점이 정말 편리한 설정이라고 생각합니다. 대상 시스템의 SSH 호스트 키를 이용해 시크릿을 암호화하는 방식은 정말 우아하게 느껴집니다.

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

원문은 Michael Stapelberg님이 에 게재했습니다.

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