`sops-nix`, `nix-darwin` and NixOS

Im currently trying to migrate some of my secrets management to sops-nix. To get a better feeling for it, I wanted to play around with it a bit on some toy examples, but this is already causing me to struggle with details.

Lets assume we have two machines:

  • A: a macOS machine, configured through nix-darwin,
  • B: a NixOS VM that was just created for testing.

The secrets setup looks like this: three files, one for each machine and one with shared secrets.

# A.yaml
a: A

# B.yaml
b: B

# shared.yaml
shared: SHARED

with a .sops.yaml file that looks like this

keys:
  - &host_A age1XXXXXXXXXXXXXXXXXXXXXXXXX
  - &host_B ssh-ed25519 XXXXXXXXXXXXXXXXXXX root@B

creation_rules:
  - path_regex: A\.yaml$
    key_groups:
      - age:
          - *host_A
  - path_regex: B\.yaml$
    key_groups:
      - age:
          - *host_B
  - path_regex: shared\.yaml$
    key_groups:
      - age:
          - *host_B
          - *host_A

where:

  • host_A is a age key generate by hand on A, and
  • host_B is the public key of /etc/ssh/ssh_host_ed25519_key on B

I run sops --encrypt ... > X.yaml on the corresponding files (or encrypt them by directly calling sops X.yaml). And the encrypted files look ok (for sample output of sops --encrypt ... command, look at the end).

I my darwin configuration I have

let
  # secrets are in separate repo, works fine, 
  # i.e. path is resolved correctly and files can be found at evaluation time
  secretspath = builtins.toString inputs.nix-secrets; 
in 
{
  sops = {
    defaultSopsFile = "${secretspath}/secrets.yaml";
    age = {
      keyFile = "/User/${user}/Library/Application Support/sops/age/keys.txt";
    };
  };
}

and in my nixOS config I have

let
  # secrets are in separate repo, works fine, 
  # i.e. path is resolved correctly and files can be found at evaluation time
  secretspath = builtins.toString inputs.nix-secrets; 
in 
{
  users.users = {
    ${user} = {
      # ....
      passwordFile = config.sops.secrets.b.path;
    };
  };

  sops = {
    defaultSopsFile = "${secretspath}/secrets/pilatus.yaml";
    age = {
      sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
    };
    secrets."b".neededForUsers = true;
  };
}

When I now rebuild the next generation on my macOS machine, everything is fine and works as expected. On the NixOS side I run into trouble:

❯ sudo nixos-rebuild switch --flake .#pilatus --show-trace
[sudo] password for iilak:
warning: Git tree '/home/iilak/nix-config' is dirty
building the system configuration...
warning: Git tree '/home/iilak/nix-config' is dirty
activating the configuration...
sops-install-secrets: Imported /etc/ssh/ssh_host_rsa_key as GPG key with fingerprint ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZz
sops-install-secrets: Imported /etc/ssh/ssh_host_ed25519_key as age key with fingerprint age1YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
/nix/store/0vf1wznjii73573f5rmh9jx8y5gvf37d-sops-install-secrets-0.0.1/bin/sops-install-secrets: failed to decrypt '/nix/store/42cnwyzvzdrwk6y6xkrkvl811y32vlgl-source/B.yaml': Error getting data key: 0 successful groups required, got 0
Activation script snippet 'setupSecretsForUsers' failed (1)
warning: password file ‘/run/secrets-for-users/b’ does not exist
setting up /etc...
Failed to run activate script
reloading user units for iilak...
restarting sysinit-reactivation.target
the following new units were started: NetworkManager-dispatcher.service
warning: error(s) occurred while switching to the new configuration

The problematic part seems to be this

Error getting data key: 0 successful groups required, got 0

I don’t understand this, since the encrypted file clearly contains a group for that key…

I tried to decrypt it manually, but there I run into

❯ sops -d /nix/store/42cnwyzvzdrwk6y6xkrkvl811y32vlgl-source/B.yaml
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXX: FAILED
    - | failed to create reader for decrypting sops data key with
      | age: no identity matched any of the recipients

Recovery failed because no master key was able to decrypt the file. In
order for SOPS to recover the file, at least one key has to be successful,
but none were.

~/nix-config feature/sops_nix !3                                                                                                                                                  ✘ 128 iilak@B ▼
❯ sudo sops -d /nix/store/42cnwyzvzdrwk6y6xkrkvl811y32vlgl-source/B.yaml
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXxx: FAILED
    - | failed to load age identities: failed to open file: open
      | /root/.config/sops/age/keys.txt: no such file or directory

Recovery failed because no master key was able to decrypt the file. In
order for SOPS to recover the file, at least one key has to be successful,
but none were.

If anybody has an idea what is going wrong here, I would really appreciate some input…

Notes

❯ sops --encrypt A.yaml
a: ENC[AES256_GCM,....]
sops:
    age:
        - recipient: age1XXXXXXXXXXXXXXXXXXXXXXX
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ........
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-06-09T20:16:13Z"
    mac: ENC[....]
    unencrypted_suffix: _unencrypted
    version: 3.10.2

/tmp/test
❯ sops --encrypt B.yaml
b: ENC[AES256_GCM,.......]
sops:
    age:
        - recipient: ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ......
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-06-09T20:16:17Z"
    mac: ENC[AES256_GCM,.....]
    unencrypted_suffix: _unencrypted
    version: 3.10.2

/tmp/test
❯ sops --encrypt shared.yaml
shared: ENC[AES256_GCM,......]
sops:
    age:
        - recipient: ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ....
            G56EZQ==
            -----END AGE ENCRYPTED FILE-----
        - recipient: age1XXXXXXXXXXXXXXXXXXXXX
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ....
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-06-09T20:16:19Z"
    mac: ENC[AES256_GCM,......]
    unencrypted_suffix: _unencrypted
    version: 3.10.2

Sops uses the ssh certificates to decrypt your secrets. Hence on host A you need those as well because it cannot use your age key (see that it is looking for it under root).

Thanks @eblechschmidt for the answer!

Im a bit confused. Host A has an age key and Host B a ssh-key. So does Host A now also need the ssh key from Host B, or vice versa, Host B the age key from Host A?

But this is somewhat orthogonal to what I thought the goal is: Be able to encrypt/decrypt my secrets with multiple keys, based on the system that I am on.

To me this would mean that there is no point of having two keys, as I could just as well copy the age key to all machines where I will need this

You can use multiple keys to encrypt the secrets. You normally have an age key for your user that you use to edit the secrets files (located in your home folder of user_A, see sops-nix/README.md at master · Mic92/sops-nix · GitHub step 2). Then you also need to provide keys for each systems where you want to automatically decrypt the secrets. You can not use ssh keys directly so you need to convert them to age keys (see sops-nix/README.md at master · Mic92/sops-nix · GitHub step 3). Note: these are the keys of the ssh server on each machine located in /etc/ssh/ssh_host_ed25519_key.pub and not ssh keys of a local user on that machine.

So what you want is something more like:

keys:
  - &user_A age1XXXXXXXXXXXXXXXXXXXXXXXXX
  - &host_A age1XXXXXXXXXXXXXXXXXXXXXXXXX
  - &host_B age1XXXXXXXXXXXXXXXXXXXXXXXXX

creation_rules:
  - path_regex: A\.yaml$
    key_groups:
      - age:
          - *user_A
          - *host_A       
  - path_regex: B\.yaml$
    key_groups:
      - age:
          - *user_A
          - *host_B
  - path_regex: shared\.yaml$
    key_groups:
      - age:
          - *user_A
          - *host_B
          - *host_A