Skip to content

Conversation

@NovaViper
Copy link

@NovaViper NovaViper commented Apr 24, 2025

Description

Continuing directly off of #680 .

I went and updated the changes to work with the most recent commits of sops-nix as well as adding in the wrapper changes introduced into the sops and age packages with NixOS/nixpkgs#395189. I can confirm it does work when you rebuild the NixOS install (and home-manager). I'm using the age-plugin-yubikey plugin on my system but all other plugins should work fine.
Additionally, I also went and added the plugin support over in the home-manager module too! I don't have a Mac to test the nix-darwin changes but I'm sure it's simply just adding the same changes into the darwin module aswell.

Some Notes

Some things oddities and things I've noticed while working on getting the PR together:

  • Using keys that doesn't require a pin seems to work more consistently (especially for the home-manager NixOS module) than those that do. From what I can tell, it's because the installer program's running in tty which doesn't have keyboard access
  • Putting the public keys generated from the plugins in a keys.txt file is crucial otherwise sops can't figure out what keys to access. And I've noticed you can put multiple public keys in the same file so that's what I did for all of my age keys from my yubikeys
  • Changing the keyFile path without rebuilding the configs first can cause you to get locked out of the secrets (the sops command itself will just refuse to decrypt the secrets). This I found out the hard way 😭
  • I haven't been able to get any neededForUsers secrets that only use the yubikeys (without some other key like ssh host derived age key) to decrypt when the system boots up. It seems to be because they're being executed before systemd even has a chance to start (because the service is using activationScripts when you're not using userborn) Actually, it maybe because the plugin can't determine the user?

Write-up of new steps to configure age plugins

  • Enable the pcscd service with services.pcscd.enable
  • Add a list of age plugins for the option sops.age.plugins and make sure to install them with environment.systemPackages so you can use the plugins to generate the keys.
    • You'll need to add in either the sops-nix overlay or override the sops package with the following in order to ensure the sops program itself reads the keys created by the plugins:
      (sops.withAgePlugins (p: [
        p.age-plugin-fido2-hmac
        p.age-plugin-yubikey
        p.age-plugin-tpm
        p.age-plugin-ledger
      ]))
    
  • Generate some age keys using the plugins
    • For age-plugin-yubikey, run age-plugin-yubikey and follow prompts (See guide for more options)
    • For age-plugin-fido2-hmac, run age-plugin-fido2-hmac -g (See the guide for more detailed information, I don't have this setup to give any further details)
    • For better reliability, make sure to generate keys that DO NOT require a PIN, as the plugins will not be able to get keyboard access in order to receive the pin when booting up a system.
  • In order to use the keys with sops itself, you must save the age identities of the keys in ~/.config/sops/age/keys.txt. This will allow sops to use your newly created age keys to be used by sops (otherwise it can't figure out which hardware-based secret key to use). The age identities are generated as a text file after you generate the age keys with the plugins
  • Set sops.age.keyFile for both NixOS and Home-manager (which in my case is /var/lib/sops/keys.txt for NixOS and /home/USERNAME/.config/sops/age/keys.txt for Home-Manager). Sops-nix then will try to use the keys listed here
  • Create secrets as you normally would, sops should then begin to use the plugins and prompt you for the newly made hardware keys.

Summary by CodeRabbit

  • New Features
    • Configurable support for sops Age plugins at system and user levels.
    • New option to declare which Age plugins are used for decryption.
    • Declared plugins are automatically added to PATH for sops operations.
    • System services that install/use secrets will include declared plugins in their execution path.
    • Default overlay now includes sops with an Age FIDO2-HMAC plugin for hardware-backed decryption.

plugins = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [
pkgs.age-plugin-fido2-hmac
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, I don't want to set a default here for the first iteration. Especially this plugin might not work very well since we cannot enter a PIN easily - Maybe this works for activation scripts but with systemd services I am not sure how this is supposed to work.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this pull request should at least describe how one can setup one age plugin of choice from start to end.

Copy link
Author

@NovaViper NovaViper May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, I don't want to set a default here for the first iteration. Especially this plugin might not work very well since we cannot enter a PIN easily - Maybe this works for activation scripts but with systemd services I am not sure how this is supposed to work.

Yeah, I can leave that option blank. 👍🏾

Speaking of systemd.. so I was trying userborn on my laptop to see if the plugins (specifically the yubikey one) works but with the sops-install-secrets-for-users service, I'm seeing warnings about the HOME variable isn't defined and it fails to interact with the key (the key flashes on like it got a request to do something but fails to do actually do the request)

● sops-install-secrets-for-users.service
     Loaded: loaded (/etc/systemd/system/sops-install-secrets-for-users.service; enabled; preset: ignored)
     Active: active (exited) since Fri 2025-05-16 10:47:05 CDT; 50min ago
 Invocation: c65e2068806247bb8ed3193542af0afc
    Process: 34406 ExecStart=/nix/store/yh0g1mxx2zcv4snnrgblxdss9mjbxbs5-sops-install-secrets-0.0.1/bin/sops-install-secrets -ignore-passwd /nix/store/b9swyn2kxpf98cdycg6yhshjm62131vw-manifest-for-users.json (code=exited, status=0/SUCCESS)
   Main PID: 34406 (code=exited, status=0/SUCCESS)
         IP: 0B in, 0B out
         IO: 0B read, 0B written
   Mem peak: 8.9M
        CPU: 60ms

May 16 10:47:05 yoganova systemd[1]: Starting sops-install-secrets-for-users.service...
May 16 10:47:05 yoganova sops-install-secrets[34406]: sops-install-secrets: Imported /etc/ssh/ssh_host_ed25519_key as age key with fingerprint age1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
May 16 10:47:05 yoganova sops-install-secrets[34406]: [AGE]         time="2025-05-16T10:47:05-05:00" level=warning msg="could not determine the user home directory: $HOME is not defined"
May 16 10:47:05 yoganova sops-install-secrets[34406]: [AGE]         time="2025-05-16T10:47:05-05:00" level=warning msg="could not determine the user home directory: $HOME is not defined"
May 16 10:47:05 yoganova sops-install-secrets[34406]: [AGE]         time="2025-05-16T10:47:05-05:00" level=warning msg="could not determine the user home directory: $HOME is not defined"
May 16 10:47:05 yoganova sops-install-secrets[34406]: [AGE]         time="2025-05-16T10:47:05-05:00" level=warning msg="could not determine the user home directory: $HOME is not defined"
May 16 10:47:05 yoganova systemd[1]: Finished sops-install-secrets-for-users.service.

Even stranger is the home-manager sops-nix service. It just completely skips the key, similarly flashing on like how I described with the install secrets service. But as soon as I turn off userborn, the home-manager service works fine for the home-manager service!

🤔 What's really odd is I've been able to get the plugin working over in agenix with home-manger but not with the user passwords (similarly had issues with the plugin not being executed during the boot process, always had to include an ssh key as a fallback key to decrypt the user passwords)

Additionally, the plugin activates normally when I switch the configurations for both Home-manager and NixOS (specifically with userborn off)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this pull request should at least describe how one can setup one age plugin of choice from start to end.

Will do! I intended to do one earlier but I forgot to do so 😭

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even stranger is the home-manager sops-nix service. It just completely skips the key, similarly flashing on like how I described with the install secrets service. But as soon as I turn off userborn, the home-manager service works fine for the home-manager service!

Ah I realized. The age key I was using on my yubikey had a PIN policy on it, switched that one out for a pinless one and now the home-manager service doesn't break anymore (with either userborn and standard! 🥳 ); but I still can't figure out that $HOME is not defined warning with the NixOS service 🤔

wantedBy = [ "systemd-sysusers.service" ];
before = [ "systemd-sysusers.service" ];
environment = cfg.environment;
environment = cfg.environment // {PATH = lib.mkForce "${cfg.environment.PATH}:${lib.makeSearchPathOutput "bin" "sbin" cfg.age.plugins}";};
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @brianmcgee for review.

@NovaViper
Copy link
Author

I also withdrew the darwin changes in case those caused breakages over on that side (I can't test that build at all since I don't have a Mac)

@NovaViper
Copy link
Author

NovaViper commented May 16, 2025

And the write up is done! @Mic92

@NovaViper NovaViper changed the title feat: add age plugin and fido2 hmac support, take 2 feat: add age plugin support, take 2 May 16, 2025
@NovaViper NovaViper force-pushed the age-plugin branch 2 times, most recently from 782693f to 162aec0 Compare May 26, 2025 17:11
@NovaViper
Copy link
Author

I completely missed the withEnvironment function that's used for the activation script in my last rebase, which caused the activation script not to use the plugins! 👀
Adding the plugins to the PATH environment variable there makes the plugins work again.

@sean-xyz
Copy link
Contributor

I am interested in TPM-based decryption at system boot. I got it working thanks to this pull request. I made a proof of concept here for a virtual machine. I also got it working on a physical machine, but not in a state to share.

I found that it works the same whether I include the sops = prev.sops.withAgePlugins overlay or not. I'm very new to nix and I didn't understand that bit, so I initially neglected it and found that it still worked.

@NovaViper
Copy link
Author

Updated to resolve some merge conflicts. Also I've noticed I can actually get the neededForUser secrets to decrypt during boot, but only when I have services like userborn enabled. I still hadn't figured out how to get it working with the activation scripts but it does work with the systemd service.

@sean-xyz
Copy link
Contributor

Updated to resolve some merge conflicts. Also I've noticed I can actually get the neededForUser secrets to decrypt during boot, but only when I have services like userborn enabled. I still hadn't figured out how to get it working with the activation scripts but it does work with the systemd service.

I had enabled userborn as well and it was working well in my virtual machine with a virtual TPM (for age-plugin-tpm). But for my physical machine I was having errors for neededForUsers secrets, as the TPM wasn't ready in time. i resolved it with config.boot.initrd.systemd.enable

sean-xyz

This comment was marked as resolved.

@sean-xyz
Copy link
Contributor

sean-xyz commented Jul 8, 2025

after having success with age-plugin-tpm, i'm now trying age-plugin-fido2-hmac and having less success. in particular, for nixos secrets loaded at boot, with or without neededForUsers. i see in your comments you're primarily using age-plugin-yubikey, have you had success with other plugins as well @NovaViper ?

I'm using the age-plugin-yubikey plugin on my system but all other plugins should work fine

age-plugin-fido2-hmac always requires user presence (e.g. touching the key), while piv (age-plugin-yubikey) doesn't. when you used piv, were you able to test it with user presence? i'm not sure if my problem is the presence check (i don't see my key flashing to prompt me) or fido2 prerequisites not loading in time

btw, in my view age-plugin-tpm has a nearly identical use case to the ssh host key route, but with the added security benefit of TPM for leaked material. the piv or ctap2 plugins may also have some use in a live-image, though i'm not sure how it can be done without exposing identity files.

@NovaViper
Copy link
Author

after having success with age-plugin-tpm, i'm now trying age-plugin-fido2-hmac and having less success. in particular, for nixos secrets loaded at boot, with or without neededForUsers. i see in your comments you're primarily using age-plugin-yubikey, have you had success with other plugins as well @NovaViper ?

@sean-xyz
Hey sorry for the extremely delayed reply! The notification got buried in my emails 😭 I hadn't tested any of the other age plugins, just only the yubikey one.

when you used piv, were you able to test it with user presence?

And yeah, all of my age keys on my yubikeys are set to use a touch confirmation. I found that having a PIN confirmation seemed to cause issues during boot but the touch based one works the most reliably.

@NovaViper
Copy link
Author

👀 Also looking I'm a good bit behind with the upstream repo, will rebase it shortly

@NovaViper
Copy link
Author

age-plugin-fido2-hmac always requires user presence (e.g. touching the key), while piv (age-plugin-yubikey) doesn't. when you used piv, were you able to test it with user presence? i'm not sure if my problem is the presence check (i don't see my key flashing to prompt me) or fido2 prerequisites not loading in time

btw, in my view age-plugin-tpm has a nearly identical use case to the ssh host key route, but with the added security benefit of TPM for leaked material. the piv or ctap2 plugins may also have some use in a live-image, though i'm not sure how it can be done without exposing identity files.

Now the identity files.. I particularly didn't use the fido2 plugin because of it exposes the identity files directly, vs the yubikey one only showing the public key. I have the identity files located under ~/.config/sops/age/keys.txt, which allows the base sops detect the keys and perform the requests.

the piv or ctap2 plugins may also have some use in a live-image, though i'm not sure how it can be done without exposing identity files.

🤔 This I'm not too sure if you can without in some way placing down the identity files somewhere. I notice without it, sops literally has no idea where the keys are, it always looks at ~/.config/sops/age/keys.txt.

@rsdy
Copy link

rsdy commented Sep 2, 2025

I wasn't able to get this branch working, whether I used the overlay or not. It seems like the overlay may not actually be needed, since you set path for the service anyway?

Sops doesn't decrypt any secrets using age-plugin-yubikey, but otherwise the box boots.
My suspicion was that it may require pcscd, but even after adding the following, I get the same results:

  systemd.services.pcscd = {
    wantedBy = ["sops-install-secrets.service"];
  };
  sops = {
    defaultSopsFile = ./secrets/${config.networking.hostName}.yaml;
    age = {
      keyFile = "/path/to/server.key";
      generateKey = false;
      plugins = [pkgs.age-plugin-yubikey];
    };
 ...
 }

Is there anything obvious I may be missing?

@NovaViper
Copy link
Author

NovaViper commented Sep 2, 2025

@rsdy Yeah you do need pcscd enabled! Also did you make sure to copy the identity files over into ~/.config/sops/age/keys.txt? Do you have any logs for it while you were trying to get the setup going? And what does the entire nix file look like, can't really tell what you're missing from the snippet alone

It seems like the overlay may not actually be needed, since you set path for the service anyway

The overlay is for the actual command that you access in the terminal when you are setting up the secrets. The service path is something different altogether. The path being set there is only applied to the environment the service will run in, not the entire system (basically anywhere that you'll run sops when you go to create the secrets as a user).

@rsdy
Copy link

rsdy commented Sep 2, 2025

It seems like in principle I needed to force systemd activation for sops-nix by enabling userborn, otherwise I could not control the execution order:

  services.userborn.enable = true;
  services.pcscd.enable = true;
  systemd.services.sops-install-secrets = {
    after = ["pcscd.service"];
    requires = ["pcscd.service"];
  };

This works now, thanks! 🎉

@NovaViper
Copy link
Author

NovaViper commented Sep 29, 2025 via email

@Ramblurr
Copy link

Ramblurr commented Oct 6, 2025

Ok, so I want to get this working with pinentry, but only for the home-manager sops-nix user service. This should be possible to do, after all the pinentry works just fine for my current PGP key setup which I'm trying to migrate away from.

Oct 06 16:46:57 quine 86mjqv1y48i01axgkn8rxvn5z5j7g96n-sops-nix-user[26616]: /nix/store/19bbka2bwy3gyk3qhkrlhygfc5y9bx8w-sops-install-secrets-0.0.1/bin/sops-install-secrets: 
failed to decrypt '/nix/store/gsxysps459v6apg6xracy6g5b9ri58sz-secrets.sops.yaml': 
Error getting data key: 0 successful groups required, got 0
Oct 06 16:46:57 quine systemd[5921]: sops-nix.service: Main process exited, code=exited, status=1/FAILURE
  1. $HOME/.config/sops/age/keys.txt exists
  2. From my shell sops decrypt /nix/store/gsxysps459v6apg6xracy6g5b9ri58sz-secrets.sops.yaml works just fine (prompted for pin and then touch the yubikey)

*Edit: Got it working. Wrong

To make this work I had to make the pinentry available to the sops-nix service.

  1. With sops.environment.PINENTRY_PROGRAM = "${pkgs.pinentry-gtk2}/bin/pinentry"; Note: while this works I don't see any explicit reference to PINENTRY_PROGRAM in the age codebase, I suspect the mere fact of including this derivation in the closure for the sops-nix service allows it to find the pinentry binary in PATH. I tried to set environment.PATH but there were conflicts.
  2. Or add home.packages = [pkgs.pinentry-gtk2];

@Ramblurr
Copy link

Ramblurr commented Oct 6, 2025

My previous comment was all wrong, neither of those work.

I thought that it worked because I happened to start sops-nix.service within 15 seconds of running a manual sops decrypt .., so the yubikey pin was cached.

Repro:

  1. sops-nix.service is failing with Error getting data key: 0 successful groups required, got 0
  2. run sops decrypt /nix/store/4h7ikhffy878bm6n7mff02r7q3cv45bz-secrets.sops.yaml enter pin, touch yubikey
  3. in the next 15 seconds systemctl --user restart sops-nix.service

Result: sops-nix decrypts the file using the yubikey succesfully.

Could this be related to #356, namely that because sops-install-secrets is using sops as a library it could be bypassing some important code for loading the plugins or pinentry?

@NovaViper
Copy link
Author

My previous comment was all wrong, neither of those work.

I thought that it worked because I happened to start sops-nix.service within 15 seconds of running a manual sops decrypt .., so the yubikey pin was cached.

Repro:

  1. sops-nix.service is failing with Error getting data key: 0 successful groups required, got 0
  2. run sops decrypt /nix/store/4h7ikhffy878bm6n7mff02r7q3cv45bz-secrets.sops.yaml enter pin, touch yubikey
  3. in the next 15 seconds systemctl --user restart sops-nix.service

Result: sops-nix decrypts the file using the yubikey succesfully.

Could this be related to #356, namely that because sops-install-secrets is using sops as a library it could be bypassing some important code for loading the plugins or pinentry?

Do you have a secret that's accessible to the user setup for the home-manager version of sops-nix? I had to create a separate ssh key for home-manager sops-nix to use along with yubikey.. I haven't been able to make it where it strictly used the yubikey for the entire operating sadly. And in reguards for pinentry support.. that's outside the scope of what I was working on, I sadly have no idea where to begin with that one (since I don't use pinentry at all)

@coderabbitai

This comment was marked as resolved.

@NovaViper
Copy link
Author

... Ok something broke majorly with that, fixing it now

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e5a38e and 11c9e30.

📒 Files selected for processing (4)
  • flake.nix (1 hunks)
  • modules/home-manager/sops.nix (2 hunks)
  • modules/sops/default.nix (3 hunks)
  • modules/sops/secrets-for-users/default.nix (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: tests
🔇 Additional comments (6)
modules/sops/secrets-for-users/default.nix (2)

20-20: LGTM: Plugin PATH correctly added to environment.

The lib.mkDefault allows users to override if needed, and lib.makeBinPath correctly constructs the PATH from the plugin packages.


40-40: LGTM: Plugin path correctly added to systemd service.

Setting path = cfg.age.plugins makes the plugin binaries available in the service's execution environment. Systemd merges path entries by default, so this won't override other paths.

modules/home-manager/sops.nix (1)

252-258: LGTM: Plugin option correctly defined.

The option definition follows the standard pattern with appropriate type (listOf package), sensible default (empty list), and clear description.

modules/sops/default.nix (3)

333-339: LGTM: Plugin option correctly defined.

The option definition follows the standard pattern with appropriate type (listOf package), sensible default (empty list), and clear description. This is consistent with the same option defined in the home-manager module.


46-46: LGTM: Plugin PATH correctly added to environment.

The lib.mkDefault allows users to override if needed, and lib.makeBinPath correctly constructs the PATH from the plugin packages. This is consistent with the implementation in modules/sops/secrets-for-users/default.nix.


450-450: LGTM: Plugin path correctly added to systemd service.

Setting path = cfg.age.plugins uses the correct systemd option (not paths), and systemd merges path entries by default, so this won't override other paths. This addresses the past review comment about merging rather than overriding.

Based on past review comments.

Comment on lines +64 to +67

sops = prev.sops.withAgePlugins (p: [
p.age-plugin-fido2-hmac
]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the default age plugin from the overlay.

According to the maintainer's past review comments and the PR objectives, a default plugin should not be set in the first iteration. The age-plugin-fido2-hmac plugin requires user presence and may not work reliably in all contexts (boot-time, systemd services, etc.). Including it as a default:

  1. Creates an unnecessary dependency for users who don't need this specific plugin
  2. Goes against the maintainer's explicit request to not set a default
  3. May cause confusion about which plugin to use

Based on past review comments and PR objectives.

Apply this diff to remove the default plugin:

-
-          sops = prev.sops.withAgePlugins (p: [
-              p.age-plugin-fido2-hmac
-          ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sops = prev.sops.withAgePlugins (p: [
p.age-plugin-fido2-hmac
]);
🤖 Prompt for AI Agents
In flake.nix around lines 64 to 67, the overlay currently sets a default age
plugin by adding age-plugin-fido2-hmac to sops.withAgePlugins; remove that
default so no plugin is preselected. Edit the sops.withAgePlugins invocation to
not include age-plugin-fido2-hmac (e.g., remove the entry or supply an empty
list), ensuring the overlay does not set any default age plugin.

))
];

PATH = lib.mkDefault lib.makeBinPath cfg.age.plugins;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify PATH conflicts when users define custom PATH values.

A past review comment noted that this causes conflict errors when users try to define a PATH for sops-nix. While lib.mkDefault allows users to override this value, it may not handle cases where users want to extend the PATH rather than replace it.

Consider making this conditional on cfg.age.plugins != [] to avoid setting an empty PATH when no plugins are configured, or verify that the current approach doesn't cause issues for users who need custom PATH configurations.

Based on past review comments.


🏁 Script executed:

#!/bin/bash
# Search for user configurations that might set sops.environment.PATH
# to understand potential conflict patterns

rg -n -C3 'sops\.environment\.PATH' --type=nix

Length of output: 0


🏁 Script executed:

#!/bin/bash
sed -n '340,390p' modules/home-manager/sops.nix

Length of output: 1737


🏁 Script executed:

#!/bin/bash
rg -n --type=nix 'age.plugins' -n modules/home-manager/sops.nix

Length of output: 63


🏁 Script executed:

sed -n '1,200p' modules/home-manager/sops.nix

Length of output: 5549


Conditionally set PATH only when cfg.age.plugins is non-empty
Wrap the PATH assignment in lib.mkIf (cfg.age.plugins != []) to avoid setting an empty PATH when no plugins are configured, which can break executable lookups.

🤖 Prompt for AI Agents
In modules/home-manager/sops.nix around line 359, the PATH is unconditionally
set to lib.mkDefault lib.makeBinPath cfg.age.plugins which can produce an empty
PATH; wrap this assignment with lib.mkIf (cfg.age.plugins != []) so the PATH is
only set when cfg.age.plugins is non-empty, preventing an empty PATH from being
written and preserving normal executable lookup when no plugins are configured.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants