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). diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index be11f69..8752257 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -274,6 +274,34 @@ 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 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. + ''; + }; }; gnupg = { @@ -377,9 +405,15 @@ 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"; + After = cfg.age.systemdDeps + ++ lib.optional cfg.age.requirePcscd "yubikey-touch-detector.service"; + Wants = cfg.age.systemdDeps; }; Service = { Type = "oneshot"; @@ -387,9 +421,35 @@ 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. + + 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 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 = { diff --git a/modules/sops/default.nix b/modules/sops/default.nix index 47e022d..d191767 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" ]; + }) ]; } 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}"} ''