Agenix-rekey - An agenix extension facilitating Yubikey/master-identity use by automating per-host secret rekeying

What is this?

I’ve been managing several hosts with a single flake and wanted to use a YubiKey to encrypt all secrets. When deploying the remote hosts from my machine, I’d like those secrets to be rekeyed automatically for the target hosts that require them, without having to track which host requires which secret manually.

This is what agenix-rekey provides. Transparent and cached automatic secret rekeying. Since rekeying is fundamentally an impure operation, I had to come up with some unconventional ideas to make this usable in practice. The github page has the full details of how it works and what features are provided.

I’d be interested to hear if anyone had the same problem and how you solved it!

7 Likes

I just went through the README.md and the code, this is genius, thanks for that.

EDIT: The tutorial in the README.md or the option descriptions in the code don’t seem to be enough for me to understand how to use it. Here’s my situation:
one yubikey for encrypting/decrypting, one backup yubikey and multiple ssh host pubkeys in /etc/ssh/ssh_host_ed25519_key.pub.

What would a config for that look like? I thought of this, but it does not seem right, where does the primary yubikey age recipient pubkey goes? also using hostPubkey with nix run .#rekey complains that it needs to use --impure

{ inputs, ... }:

{
  rekey = {
    hostPubkey = /etc/ssh/ssh_host_ed25519_key.pub;
    masterIdentities = [ "${inputs.self}/secrets/identities/yubikey-5.txt" ];
    extraEncryptionPubkeys = [ "age1yubikey1qwfyruye5pqywhekru234lhaahwpfwmmhf6xesr6yla3zmf0m0u5jmqft80" ];
  };
}
1 Like

I’m sorry, the wording for some options can probably still be improved. But I believe you’re pretty close. I’ll try to clarify as good as I can:

  • The impurity probably comes from using /etc/ssh/ssh_host_ed25519_key.pub as a path, which is outside of your flake. I’d recommend either storing a copy of this in your flake, or changing it to a string like "/etc/ssh/ssh_host_ed25519_key.pub" to allow the impure rekeying context to access it directly. The latter is only practical if you manage just one single host. If you have more than one, you always want to store a copy in your flake, since rekeying needs access to all host’s pubkeys.

  • masterIdentities is the a list of identities that are presented to (r)age on any decryption (for rekeying). I’d personally recommend only putting your primary YubiKey there, but you can put both here if you want to use them interchangeably. IIRC rage tries identities in-order, so you might have to “deny” the first request when you want to use your second key, which can be a hassle. The given file(s) should be stored in your flake and contain the split-identity, so essentially some comments plus AGE-PLUGIN-YUBIKEY-<SOMEID>.

  • extraEncryptionPubkeys are additional public keys for which all your secrets will be encrypted whenever you create/edit a secret using nix run .#edit-secret, in addition to all masterIdentities. You should put your second YubiKey here if you haven’t put it in masterIdentities, which is what you did. I personally use a regular age identity here that is stored somewhere safe, since I don’t have a spare YubiKey.

So this should be a possible configuration for your purposes:

{ inputs, ... }:

{
  rekey = {
    hostPubkey = "/etc/ssh/ssh_host_ed25519_key.pub"; # Or use a string with the file's content, or copy this file to the flake and use a path.
    masterIdentities = [ "${inputs.self}/secrets/identities/yubikey-5.txt" ]; # primary yubikey split-identity file
    extraEncryptionPubkeys = [ "age1yubikey1qwfyruye5pqywhekru234lhaahwpfwmmhf6xesr6yla3zmf0m0u5jmqft80" ]; # backup keys that are never used for decryption
  };
}

Let me know if anything’s still unclear!

EDIT: I updated the repository readme with clarified descriptions and added module options to the main README.

The latter is only practical if you manage just one single host. If you have more than one, you always want to store a copy in your flake, since rekeying needs access to all host’s pubkeys.

  1. That option does not take a list.
  2. adding quotes to /etc/ssh/ssh_host_ed25519_key.pub does not magically turn it into a string if inside your implementation you are converting it to an absolute path. (Getting the same error about absolute path even when turning it to a string.)
  3. Does masterIdentities get the recipient from the comments of the file generated by age-plugin-yubikey?
  4. How does agenix-rekey actually help with bootstrapping if you need to manually copy all the host’s pubkeys to your repo before you’re able to use them for rekeying?

Will this work with sk-ssh-ed25519 Discoverable Credentials keys generated using ssh-keygen -t ed25519-sk -O resident -O application=ssh: -O verify-required?

Basically any application or cloud I tried it with never heard of sk- keys but there is no way I am going back to anything else its just too convenient and I also think more secure because even with both of your key files* and passphrase the attacker still can’t do much except make you plug the key and touch it.

*not totally sure but I think even the non .pub key file is useless as the private key is stored on device with no way of extracting it

It doesn’t have to, because the module is imported on each host separately. So you define the correct value for the host that uses the module.

Thanks for noticing, I was expecting coerceTo to only call the coerce function (readFile) when the fromType matches the given type instead of checking compatibility with the toType. Since a string starting with "/" and therefore can be converted to path, it unnecessarily invokes readFile. My assumption was that the function is called when necessary and not opportunistic. Fixed now.

Yes, kind of, rage just does that automatically. You can just use identities via -i for encryption.

You can skip the option and a dummy pubkey will be used when rekeing. Agenix activation will fail but you can still successfully deploy your host and it will generates its ssh hostkey, which can then be determined with ssh-keyscan -H <host> or alternatively by copying. I don’t think this process can be reasonably automated an further, since fundamentally you have to know the pubkey of the host before you can encrypt stuff for it. And the ssh hostkey only exists after starting the host for the first time.

In my flake I read the pubkey from pubkeys/.pub if it exists and use the default otherwise. That eases bootstrapping because I can build and deploy an ISO image, run ssh-keyscan -H <host> > pubkeys/<host>.pub and simply redeploy.

1 Like

Nothing in this flake prevents it from working in theory, but I don’t know if rage supports this. If it does, it should work, as long as your ssh-agent exposes access to the key. Technically there’s not much difference between an sk- key and an age identity stored on a yubikey. Let me know if it works in case you try that!

Also, the sk- keys are just so-called keygrabs, i.e. identifiers for the actual identity that is stored on the key. So they indeed contain no secret information, and are safe to commit.

1 Like

Rage/Age do not support -sk ssh keys.

If anyone is wondering why there is explanation in github comment

I’ll also note here that it is impossible to support sk-* SSH keys. These use the FIDO2 protocol, which only supports authentication; it doesn’t expose the necessary primitive to also implement asymmetric encryption.

Bummer, but there is age-plugin-yubikey which uses PIV instead of FIDO2. I tried it while back using these scripts and it worked. Having support for it would be great but I imagine there are not many people using sk- keys overall.

lock.sh

#!/usr/bin/env bash

set -e

recipient="$(age-plugin-yubikey --list | grep -v '#' | tr -d '[:space:]' | head -n 1)"

echo "🔒 $1.age"
echo "👀 $recipient"

rage \
  --recipient "$recipient" \
  --armor \
  --output "$1.age" \
  "$1"

rm -rf $1

unlock.sh

#!/usr/bin/env bash

set -e

identity_file="$HOME/.age-identity"

if [[ ! $1 =~ \.age$ ]]; then
  echo "Error: $1 does not end with .age"
  exit 1
fi

if [[ ! -f $identity_file ]]; then
  echo "Creating $identity_file"
  age-plugin-yubikey --identity > "$identity_file"
fi

echo "You are going to be asked for PIV pin and presence confirmation"

rage --decrypt --identity "$identity_file" --output "${1%.age}" "$1"

rm -rf "$1"
1 Like

Is there an easy way to target all the rekey’d secrets so that I can copy them to a remote store?

You can directly access the function that generates the derivation. Since there’s no extra logic required, I didn’t include any specific aggregation attribute. Adding the following line to your flake output should be enough:

outputs = { self, agenix-rekey, ... }: {
  # ... apps=, nixosConfigurations=, ...
  # pkgs here is whatever you used to define the rekeying app
  rekeyedSecretPackages = lib.mapAttrsToList (_: hostAttrs: import (agenix-rekey + "/nix/output-derivation.nix") pkgs hostAttrs.config) self.nixosConfigurations;
}

Side note: Usually this won’t be necessary, as deployment utilities like colmena will automatically copy any derivations required for the remote if they already exist locally. This probably is only handy if you want to manually copy it for some reason, which of course is also fine.