Skip to content
103 changes: 103 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
62 changes: 61 additions & 1 deletion modules/home-manager/sops.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -372,19 +400,51 @@ 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";
Environment = builtins.concatStringsSep " " (
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
Expand Down
16 changes: 16 additions & 0 deletions modules/nix-darwin/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
76 changes: 75 additions & 1 deletion modules/sops/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand All @@ -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...
Expand Down Expand Up @@ -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" ];
})
];
}
8 changes: 7 additions & 1 deletion modules/sops/secrets-for-users/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}"}
''
Expand Down
Loading