From 31f9f6cdb43fdd79123be6f858a90b1e3d7caa8b Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:14:36 +0000 Subject: [PATCH 1/9] feat(nixos): add YubiKey/FIDO2 age plugin support options Add new options under sops.age for hardware key plugin support: - activationScriptDeps: custom activation script dependencies - systemdDeps: custom systemd unit dependencies - requirePcscd: convenience option that auto-configures pcscd When requirePcscd is enabled: - Adds pcscd.socket as systemd dependency (systemd activation mode) - Creates setupPcscdForSops activation script (traditional mode) - Validates that services.pcscd.enable is set This addresses GitHub issue #377 for YubiKey-hosted age keys. --- modules/sops/default.nix | 76 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/modules/sops/default.nix b/modules/sops/default.nix index 6a2197a..6dc5045 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -369,6 +369,43 @@ in Paths to ssh keys added as age keys during sops description. ''; }; + + # Options for hardware key support (YubiKey, FIDO2, etc.) + activationScriptDeps = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "setupPcscdForSops" ]; + description = '' + Additional activation script names that must complete before + setupSecrets and setupSecretsForUsers run. This is useful when + using age plugins that require external services like pcscd. + ''; + }; + + systemdDeps = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "pcscd.socket" ]; + description = '' + Additional systemd units that sops-install-secrets should depend on + when using systemd activation mode. This is useful when using age + plugins that require external services like pcscd. + ''; + }; + + requirePcscd = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether pcscd (PC/SC Smart Card Daemon) is required for age decryption. + Enable this when using hardware key plugins like age-plugin-yubikey + or age-plugin-fido2-hmac. This automatically configures the necessary + dependencies to ensure pcscd is running before secrets are decrypted. + + Note: You must also enable `services.pcscd.enable = true` in your + NixOS configuration for this to work. + ''; + }; }; gnupg = { @@ -467,7 +504,8 @@ in # When using sysusers we no longer are started as an activation script because those are started in initrd while sysusers is started later. systemd.services.sops-install-secrets = lib.mkIf (regularSecrets != { } && cfg.useSystemdActivation) { wantedBy = [ "sysinit.target" ]; - after = [ "systemd-sysusers.service" "userborn.service" ]; + after = [ "systemd-sysusers.service" "userborn.service" ] ++ cfg.age.systemdDeps; + wants = cfg.age.systemdDeps; requiredBy = [ "sysinit-reactivation.target" ]; before = [ "sysinit-reactivation.target" ]; environment = cfg.environment; @@ -491,6 +529,7 @@ in "groups" ] ++ lib.optional cfg.age.generateKey "generate-age-key" + ++ cfg.age.activationScriptDeps ) '' [ -e /run/current-system ] || echo setting up secrets... @@ -520,5 +559,40 @@ in { system.build.sops-nix-manifest = manifest; } + + # Automatic pcscd configuration for hardware key plugins + (lib.mkIf cfg.age.requirePcscd { + assertions = [ + { + assertion = config.services.pcscd.enable or false; + message = '' + sops.age.requirePcscd is enabled but services.pcscd.enable is not set. + Please add `services.pcscd.enable = true;` to your configuration. + ''; + } + ]; + + # Add pcscd.socket as a systemd dependency + sops.age.systemdDeps = [ "pcscd.socket" ]; + + # For activation script mode, ensure pcscd is started before secrets + system.activationScripts.setupPcscdForSops = lib.mkIf (!cfg.useSystemdActivation) ( + lib.stringAfter [ "specialfs" ] '' + # Ensure pcscd drivers are available + mkdir -p /var/lib/pcsc + ln -sfn ${pkgs.ccid}/pcsc/drivers /var/lib/pcsc/drivers + + # Try to start pcscd via socket activation, or directly if needed + if ! ${pkgs.systemd}/bin/systemctl is-active --quiet pcscd.socket 2>/dev/null; then + if ! ${pkgs.systemd}/bin/systemctl is-active --quiet pcscd.service 2>/dev/null; then + # Start pcscd directly with auto-exit for activation script context + ${pkgs.pcsclite}/bin/pcscd --auto-exit 2>/dev/null || true + fi + fi + '' + ); + + sops.age.activationScriptDeps = lib.mkIf (!cfg.useSystemdActivation) [ "setupPcscdForSops" ]; + }) ]; } From 84a8698b98931c05211925b9d01037697967f2dc Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:15:00 +0000 Subject: [PATCH 2/9] feat(nixos): wire hardware key deps to secrets-for-users module Add support for activationScriptDeps and systemdDeps in the secrets-for-users module, ensuring pcscd dependencies are respected for secrets that need to be available before user creation (neededForUsers = true). --- modules/sops/secrets-for-users/default.nix | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/sops/secrets-for-users/default.nix b/modules/sops/secrets-for-users/default.nix index 841eb13..69bcc10 100644 --- a/modules/sops/secrets-for-users/default.nix +++ b/modules/sops/secrets-for-users/default.nix @@ -35,6 +35,8 @@ in { wantedBy = [ "systemd-sysusers.service" ]; before = [ "systemd-sysusers.service" ]; + after = cfg.age.systemdDeps; + wants = cfg.age.systemdDeps; environment = cfg.environment; unitConfig.DefaultDependencies = "no"; path = cfg.age.plugins; @@ -48,7 +50,11 @@ in system.activationScripts = lib.mkIf (secretsForUsers != { } && !useSystemdActivation) { setupSecretsForUsers = - lib.stringAfter ([ "specialfs" ] ++ lib.optional cfg.age.generateKey "generate-age-key") '' + lib.stringAfter ( + [ "specialfs" ] + ++ lib.optional cfg.age.generateKey "generate-age-key" + ++ cfg.age.activationScriptDeps + ) '' [ -e /run/current-system ] || echo setting up secrets for users... ${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"} '' From 33119dd52cedbdeae8ce87dd58fe31e9917f16c3 Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:15:31 +0000 Subject: [PATCH 3/9] feat(home-manager): add YubiKey/FIDO2 age plugin support Add new options under sops.age for hardware key plugin support: - systemdDeps: custom systemd unit dependencies for sops-nix service - requirePcscd: convenience option that auto-adds pcscd.socket dependency The systemd user service now respects After= and Wants= for the configured dependencies. --- modules/home-manager/sops.nix | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index e340198..85209dc 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -274,6 +274,31 @@ in Paths to ssh keys added as age keys during sops description. ''; }; + + # Options for hardware key support (YubiKey, FIDO2, etc.) + systemdDeps = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "pcscd.socket" ]; + description = '' + Additional systemd units that the sops-nix user service should depend on. + This is useful when using age plugins that require external services like pcscd. + ''; + }; + + requirePcscd = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether pcscd (PC/SC Smart Card Daemon) is required for age decryption. + Enable this when using hardware key plugins like age-plugin-yubikey + or age-plugin-fido2-hmac. This automatically configures the systemd + service to depend on pcscd.socket. + + Note: The system must have pcscd available (usually via + `services.pcscd.enable = true` in your NixOS configuration). + ''; + }; }; gnupg = { @@ -375,6 +400,8 @@ in systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { Unit = { Description = "sops-nix activation"; + After = cfg.age.systemdDeps; + Wants = cfg.age.systemdDeps; }; Service = { Type = "oneshot"; @@ -387,6 +414,9 @@ in if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ]; }; + # Auto-configure pcscd dependency when requirePcscd is enabled + sops.age.systemdDeps = lib.mkIf cfg.age.requirePcscd [ "pcscd.socket" ]; + # Darwin: load secrets once on login launchd.agents.sops-nix = { enable = true; From b96d73924b22bdac79f812e3e26accddec2d288d Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:15:56 +0000 Subject: [PATCH 4/9] feat(nix-darwin): add requirePcscd option for consistency Add the requirePcscd option to the nix-darwin module for consistency with NixOS and home-manager modules. On macOS, CryptoTokenKit typically handles YubiKey communication automatically, but this option is provided for API consistency. --- modules/nix-darwin/default.nix | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/nix-darwin/default.nix b/modules/nix-darwin/default.nix index 27331bc..b3ec176 100644 --- a/modules/nix-darwin/default.nix +++ b/modules/nix-darwin/default.nix @@ -316,6 +316,22 @@ in List of plugins to use for sops decryption. ''; }; + + # Options for hardware key support (YubiKey, FIDO2, etc.) + requirePcscd = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether pcscd (PC/SC Smart Card Daemon) is required for age decryption. + Enable this when using hardware key plugins like age-plugin-yubikey + or age-plugin-fido2-hmac. + + On macOS, the system's built-in smart card services (CryptoTokenKit) + typically handle YubiKey communication automatically. This option + is provided for consistency with Linux but may not require additional + configuration on macOS. + ''; + }; }; gnupg = { From 236132d0abab78b7e264298dbf3128060db3dbee Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:16:35 +0000 Subject: [PATCH 5/9] docs: add YubiKey/FIDO2 hardware key documentation Add comprehensive documentation section covering: - Supported plugins (age-plugin-yubikey, age-plugin-fido2-hmac) - Step-by-step setup instructions - NixOS and home-manager configuration examples - Advanced custom dependency configuration - Troubleshooting tips --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/README.md b/README.md index 328be60..86ed405 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,109 @@ The other method is `age` which is based on [`age`](https://github.com/FiloSotti The tool ([`ssh-to-age`](https://github.com/Mic92/ssh-to-age)) can convert SSH host or user keys in Ed25519 format to `age` keys. +## Using hardware keys (YubiKey/FIDO2) with age + +sops-nix supports using age keys stored on hardware security devices like YubiKeys through age plugins. This provides an additional layer of security by requiring physical access to the hardware key for decryption. + +### Supported plugins + +- [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey): For YubiKey devices +- [`age-plugin-fido2-hmac`](https://github.com/olastor/age-plugin-fido2-hmac): For any FIDO2-compatible security key + +### Setup + +1. **Enable pcscd service** (required for communication with the hardware key): + + ```nix + { + services.pcscd.enable = true; + } + ``` + +2. **Generate a YubiKey-hosted age identity**: + + ```console + $ nix-shell -p age-plugin-yubikey + $ age-plugin-yubikey --generate + ``` + + This creates an identity file (e.g., `age-yubikey-identity-XXXXXXXX.txt`) containing: + - The age recipient (public key) as a comment + - The age identity for decryption + +3. **Add the age recipient to your `.sops.yaml`**: + + ```yaml + keys: + - &yubikey age1yubikey1q...your-recipient-here... + creation_rules: + - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$ + key_groups: + - age: + - *yubikey + ``` + +4. **Configure sops-nix**: + + ```nix + { + services.pcscd.enable = true; + + sops = { + age = { + keyFile = "/path/to/age-yubikey-identity-XXXXXXXX.txt"; + plugins = [ pkgs.age-plugin-yubikey ]; + requirePcscd = true; # Ensures pcscd is available during decryption + }; + + secrets.my-secret = { + sopsFile = ./secrets.yaml; + }; + }; + } + ``` + +### Home-Manager configuration + +For home-manager users: + +```nix +{ + sops = { + age = { + keyFile = "/home/user/age-yubikey-identity.txt"; + plugins = [ pkgs.age-plugin-yubikey ]; + requirePcscd = true; + }; + + secrets.my-secret = { }; + }; +} +``` + +### Advanced: Custom dependencies + +If you need more control over the activation order or have custom requirements, you can use the lower-level options: + +```nix +{ + sops.age = { + # For activation script mode: additional scripts to run before setupSecrets + activationScriptDeps = [ "my-custom-setup-script" ]; + + # For systemd activation mode: additional units to depend on + systemdDeps = [ "my-custom.service" ]; + }; +} +``` + +### Troubleshooting + +- **"No key source configured"**: Ensure `sops.age.keyFile` points to your YubiKey identity file +- **Plugin not found**: Make sure `age-plugin-yubikey` is in `sops.age.plugins` +- **pcscd not running**: Enable `services.pcscd.enable = true` and ensure `sops.age.requirePcscd = true` +- **Touch required**: Some YubiKey configurations require physical touch during decryption; ensure you're present during system activation + ## Usage example If you prefer video over the textual description below, you can also checkout this [6min tutorial](https://www.youtube.com/watch?v=G5f6GC7SnhU) by [@vimjoyer](https://github.com/vimjoyer). From 83f259f34f67d4ed67ac193866df5a8d309c985e Mon Sep 17 00:00:00 2001 From: Ryota Date: Sat, 17 Jan 2026 02:51:59 +0000 Subject: [PATCH 6/9] fix(home-manager): improve pcscd availability check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check for /run/pcscd/pcscd.comm socket file directly instead of using systemctl, which may have D-Bus permission issues when running as a user service. The socket file is created by systemd when pcscd.socket is active and listening. Increased wait time to 6 seconds (30 iterations × 0.2s) to handle slower boot scenarios. --- modules/home-manager/sops.nix | 36 ++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index 85209dc..725991e 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -292,11 +292,14 @@ in description = '' Whether pcscd (PC/SC Smart Card Daemon) is required for age decryption. Enable this when using hardware key plugins like age-plugin-yubikey - or age-plugin-fido2-hmac. This automatically configures the systemd - service to depend on pcscd.socket. + or age-plugin-fido2-hmac. - Note: The system must have pcscd available (usually via - `services.pcscd.enable = true` in your NixOS configuration). + This adds a pre-start check to wait for pcscd to be available before + attempting decryption. + + Note: You must also enable `services.pcscd.enable = true` in your + NixOS configuration. The pcscd service runs at the system level and + will be socket-activated when the YubiKey is accessed. ''; }; }; @@ -397,6 +400,9 @@ in ); }; + # Note: pcscd.socket is a system service, not a user service, so we cannot + # add it as a direct dependency for requirePcscd. Instead, we add a pre-start + # script that waits for pcscd to be available. systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { Unit = { Description = "sops-nix activation"; @@ -409,14 +415,30 @@ in lib.mapAttrsToList (name: value: "'${name}=${value}'") cfg.environment ); ExecStart = script; + ExecStartPre = lib.mkIf cfg.age.requirePcscd [ + "${pkgs.writeShellScript "wait-for-pcscd" '' + # Ensure pcscd is available for YubiKey communication. + # When pcscd.socket is enabled, systemd creates /run/pcscd/pcscd.comm + # and starts pcscd.service on-demand when the socket is accessed. + + for i in $(seq 1 30); do + # Check if the pcscd socket file exists - this is the most reliable check + # and doesn't require D-Bus access + if [ -e /run/pcscd/pcscd.comm ]; then + exit 0 + fi + sleep 0.2 + done + + echo "Warning: pcscd socket not found at /run/pcscd/pcscd.comm" >&2 + echo "YubiKey decryption may fail. Ensure services.pcscd.enable = true" >&2 + ''}" + ]; }; Install.WantedBy = if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ]; }; - # Auto-configure pcscd dependency when requirePcscd is enabled - sops.age.systemdDeps = lib.mkIf cfg.age.requirePcscd [ "pcscd.socket" ]; - # Darwin: load secrets once on login launchd.agents.sops-nix = { enable = true; From dae3f7896f1b289fb613a0f2d7e6f3320cde1a3c Mon Sep 17 00:00:00 2001 From: Ryota Date: Thu, 29 Jan 2026 20:26:30 +0000 Subject: [PATCH 7/9] systemd: fix target handling and script for pcscd --- modules/home-manager/sops.nix | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index 725991e..0b93194 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -420,23 +420,30 @@ in # Ensure pcscd is available for YubiKey communication. # When pcscd.socket is enabled, systemd creates /run/pcscd/pcscd.comm # and starts pcscd.service on-demand when the socket is accessed. - - for i in $(seq 1 30); do + + i=0 + while [ $i -lt 30 ]; do # Check if the pcscd socket file exists - this is the most reliable check # and doesn't require D-Bus access if [ -e /run/pcscd/pcscd.comm ]; then exit 0 fi sleep 0.2 + i=$((i + 1)) done - + echo "Warning: pcscd socket not found at /run/pcscd/pcscd.comm" >&2 echo "YubiKey decryption may fail. Ensure services.pcscd.enable = true" >&2 ''}" ]; }; Install.WantedBy = - if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ]; + # When pcscd is required, we need to wait for the graphical session to be active + # so that polkit recognizes it as an active session and allows pcscd access. + # Otherwise, we run at default.target for faster boot times. + if cfg.gnupg.home != null || cfg.age.requirePcscd + then [ "graphical-session-pre.target" ] + else [ "default.target" ]; }; # Darwin: load secrets once on login From 9eb31470953e819909d537dd0d97394898cb583c Mon Sep 17 00:00:00 2001 From: Ryota Date: Thu, 29 Jan 2026 21:31:23 +0000 Subject: [PATCH 8/9] systemd: check for yubikey-touch-detector --- modules/home-manager/sops.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index 0b93194..66896d0 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -8,6 +8,9 @@ let cfg = config.sops; sops-install-secrets = cfg.package; + + # Check if yubikey-touch-detector is enabled for ordering dependency + yubikeyTouchDetectorEnabled = config.services.yubikey-touch-detector.enable or false; secretType = lib.types.submodule ( { name, ... }: { @@ -406,7 +409,9 @@ in systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { Unit = { Description = "sops-nix activation"; - After = cfg.age.systemdDeps; + After = cfg.age.systemdDeps + ++ lib.optional (cfg.age.requirePcscd && yubikeyTouchDetectorEnabled) + "yubikey-touch-detector.service"; Wants = cfg.age.systemdDeps; }; Service = { From 56365fb9a8cf562dc12f917693de76315f9788b7 Mon Sep 17 00:00:00 2001 From: Ryota Date: Thu, 29 Jan 2026 21:50:18 +0000 Subject: [PATCH 9/9] systemd: hard-code yubikey-touch-detector for now as it's not a HM module --- modules/home-manager/sops.nix | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index 66896d0..f8eec36 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -8,9 +8,6 @@ let cfg = config.sops; sops-install-secrets = cfg.package; - - # Check if yubikey-touch-detector is enabled for ordering dependency - yubikeyTouchDetectorEnabled = config.services.yubikey-touch-detector.enable or false; secretType = lib.types.submodule ( { name, ... }: { @@ -410,8 +407,7 @@ in Unit = { Description = "sops-nix activation"; After = cfg.age.systemdDeps - ++ lib.optional (cfg.age.requirePcscd && yubikeyTouchDetectorEnabled) - "yubikey-touch-detector.service"; + ++ lib.optional cfg.age.requirePcscd "yubikey-touch-detector.service"; Wants = cfg.age.systemdDeps; }; Service = {