This commit is contained in:
Ryota 2026-02-10 12:11:54 +00:00 committed by GitHub
commit ed14334b6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 262 additions and 3 deletions

103
README.md
View file

@ -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).

View file

@ -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

View file

@ -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 = {

View file

@ -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" ];
})
];
}

View file

@ -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}"}
''