oddlama / agenix-rekey

An agenix extension adding secret generation and automatic rekeying using a YubiKey or master-identity
MIT License
213 stars 18 forks source link

SSH keypair or public file generation support #11

Closed axelkar closed 7 months ago

axelkar commented 10 months ago

I want to generate a private SSH key and distribute it to only one host. Then I want to generate a public key to use on only one NixOS host but users.users.<name>.openssh.authorizedKeys.keyFiles only takes in build-time paths. Is there a way to do this with agenix-rekey's generation system?

axelkar commented 10 months ago

Oh and I'd like to ask on how to do the specific secrets for specific hosts thing.

oddlama commented 10 months ago

Technically yes, but it depends on whether you are comfortable using this. Manually generating the private and public key pair is always an option, and you might consider that "cleaner" than what I'm going to propose now.

Since agenix rekey is run separately and generates files before you actually build the system, you can make it accessible at build time. The issue is that by using agenix-rekey you will end up with encrypted files, which cannot be imported easily at build-time.

Option A is quite hacky, but by using nix-plugins you can define a new builtin that allows you to import encrypted files at build time. This is what I am doing in my personal repository to store PII, and this could read the host pubkey which you can derive via agenix-rekey. See below for a full example.

Option B would be to use a generator that automatically places the public key alongside the rekeyed file.

age.secrets.ssh-private-key = {
  rekeyFile = ./hosts1/secrets/id_ed25519;
  generator.script = { pkgs, file, ... }: ''
    privkey=$( (exec 3>&1; ${pkgs.openssh}/bin/ssh-keygen -q -t ed25519 -N "" -f /proc/self/fd/3 <<<y >/dev/null 2>&1; true) )
    # Derive public key from private key
    echo "$privkey" | (exec 3<&0; ssh-keygen -f /proc/self/fd/3 -y) >${lib.escapeShellArg file}.pub
    # Only output private key for this secret
    echo "$privkey"
  '';
};

Now you can just refer to ./host1/secrets/id_ed25519.pub from anywhere.


Generating a encrypted pubkey

Assuming you want to use nix-plugins, you can let agenix-rekey derive the public key via a dependency and let it be stored in an encrypted format. Then you will import the file using a custom builtin. Not recommended, but works :)

There already is a builtin generator for private keys, and then you can derive the public key from that in a second secret. For this to work properly, you need to have access to the other nodes configuration via your specialArgs, so whatever you set for agenix-rekey.nodes in your flake.nix should also be accessible in your host's configuration.nix in case you don't have that already. Then you can make the public key's secret depend on the other host's private key secret so it will automatically be generated in case the secret key changes.

So assuming you have access to your other nodes via nodes.<name> in your nixos configuration, you can do the following:

host1's configuration.nix (the host using the private key):

{
  age.secrets.ssh-private-key = {
    rekeyFile = ./secrets/id_ed25519;
    generator.script = "ssh-ed25519";
  };
}

host2's configuration.nix (derives and uses the public key):

{ nodes, ... }: {
  age.secrets.host1-ssh-public-key = {
    rekeyFile = ./secrets/host1_id_ed25519.pub;
    generator.dependencies = [
      nodes.host1.config.age.secrets.ssh-private-key
    ];
    generator.script = { lib, decrypt, deps, ... }: ''
      # Derive public key from private key
      ${decrypt} ${lib.escapeShellArg (lib.head deps).file} | (exec 3<&0; ssh-keygen -f /proc/self/fd/3 -y)
    '';
  };
}
oddlama commented 10 months ago

Sorry I misread your question initially. My answer is now updated :)

axelkar commented 10 months ago

>${lib.escapeShellArg file}.pub

I'm a little worried Nix would just make file a store path but I haven't looked at that part of the library's source code yet so maybe you have something to kill the string context or make rekeyFile a string when generating.

./secrets/id_ed25519, ./secrets/host1_id_ed25519.pub

Is there a reason the file paths don't end in .age?

Thank you for the help!

axelkar commented 10 months ago

Btw is the rekeyed secret derivation hash really different depending on the target hosts's system?

https://github.com/oddlama/agenix-rekey/blob/main/nix/output-derivation.nix#L68 Why don't you just make a tempdir in the agenix rekey command wherever and let Nix copy the path (yes Nix path type)?

# pseudo bash code for `agenix rekey`
originalSecret=${config.age.secrets.foo.rekeyFile passed in from nix}
originalSecretHash="$(sha256sum $originalSecret | cut -d " " -f 1)"
decrypt $originalSecret | rage -r $hostPubkey > /tmp/agenix-rekey/"$(echo -ne "$originalSecretHash$hostPubkey" | sha256sum | cut -d " " -f 1)"
{ config, ... }:
let
  originalSecretHash = builtins.hashFile "sha256" config.age.secrets.foo.rekeyFile;
  hostPubkey = config.age.rekey.hostPubkey;
  hash = builtins.hashString "sha256" "${originalSecretHash}${hostPubkey}";
  cachePath = /tmp/agenix-rekey/${hash}; # Will have to be evaluated on the machine `agenix rekey` was run at but you might be able to wrap this in a builtins.readFile + pkgs.writeText to make it a derivation you can realise on the current host and then `nix3-copy` over
in {
  activationScripts.whatever.text = ''
    mkdir /run/agenix
    rage -d -i /etc/ssh_host_key_location ${cachePath} > /run/agenix/foo
  '';
}

EDIT: builtins.readFile + pkgs.writeText don't seem to fix the problem of changed hashes on different systems but my solution is cleaner imo

EDIT: you might achieve a similar effect by changing age.rekey.cacheDir to the path type and removing UID stuff. I don't think it would require extra-sandbox-paths or anything https://github.com/oddlama/agenix-rekey/blob/main/modules/agenix-rekey.nix#L321-L323

oddlama commented 10 months ago

>${lib.escapeShellArg file}.pub

I'm a little worried Nix would just make file a store path but I haven't looked at that part of the library's source code yet so maybe you have something to kill the string context or make rekeyFile a string when generating.

I deliberately remove the context here.

./secrets/id_ed25519, ./secrets/host1_id_ed25519.pub

Is there a reason the file paths don't end in .age?

No they should be .age files. I just typed that from memory and forgot to add it :D

Btw is the rekeyed secret derivation hash really different depending on the target hosts's system?

Yes, because the derivation is a shell script and that depends on bash which depends on the system. I guess content-addressed derivations might solve this in the future.

https://github.com/oddlama/agenix-rekey/blob/main/nix/output-derivation.nix#L68 Why don't you just make a tempdir in the agenix rekey command wherever and let Nix copy the path (yes Nix path type)?

Because it is important that the output path of the secret derivation can be known without actually rekeying the secrets. How would you reference the current up-to-date rekeyed secret for a host in its configuration if you manually copied a path to the store? The rekeyed secrets must end up in the store after rekeying, so that you can deploy to other hosts without them having to care about agenix-rekey.

Currently the whole thing works like this:

I'm not 100% sure I followed your example, so please correct me if I am misunderstanding you.

axelkar commented 8 months ago

https://github.com/oddlama/agenix-rekey/blob/main/nix/output-derivation.nix#L68 Why don't you just make a tempdir in the agenix rekey command wherever and let Nix copy the path (yes Nix path type)?

Because it is important that the output path of the secret derivation can be known without actually rekeying the secrets. How would you reference the current up-to-date rekeyed secret for a host in its configuration if you manually copied a path to the store? The rekeyed secrets must end up in the store after rekeying, so that you can deploy to other hosts without them having to care about agenix-rekey.

  • The derivation containing the rekeyed secrets has a trivial builder that copies rekeyed secrets from a pre-determined path. If these don't exist, the derivation deliberately fails and user is thus prompted to rekey.

Just thought of this again. Why don't you do something like this in agenix-rekey:

originalSecret=./secrets/foo
masterIdentity=~/agenix-identity
for host in a b c ; do
  hostPubkey=./hosts/$host/hostkey.pub
  rekeyedSecret=./hosts/$host/rekeyed/foo.age
  rage -d -i $masterIdentity | rage -e -r $hostPubkey > $rekeyedSecret
done

# if flake is not referenced using path://
git add ./hosts/*/rekeyed
git commit -m "chore: rekey secrets"
{ config, ... }:
let
 host = "a";
 hostRekeyedDir = ./hosts/${host}/rekeyed;
in {
  age.secrets.foo.file = let
    path = /${hostRekeyedDir}/${config.age.secrets.foo.id}.age;
  in if builtins.pathExists path then path else builtins.abort "Please rekey for host ${host}!"
}

As the flake goes to any eval hosts, ./hosts/*/rekeyed should always work.

This way you don't need impurity and can build and/or evaluate on any host, even from a Git clone.

oddlama commented 8 months ago

As the flake goes to any eval hosts, ./hosts/*/rekeyed should always work.

This way you don't need impurity and can build and/or evaluate on any host, even from a Git clone.

Ah, so what you are actually proposing is to store the rekeyed secrets in the repository. (Nitpick: It's still technically impure, because rekeying is the impurity and that's still the case - it just won't be triggered by building). What this changes is that it won't require you to build a derivation for the secrets, so this will eliminate all the problems with transferring the derivation, which is cool.

I do like the idea, it's obviously an advantage for CI builds and any multi-system flake. I guess I can add this when I have some time next month. :)

Since this will change the user's assumptions about how agenix works, I think we need to make this optional somehow. Currently I'm thinking of adding a mandatory configuration value where you have to specify storageMode = "repository"; or storageMode = "derivation";. If the mode isn't set, it'll default to the old "derivation" mode and show a warning that you should decide for one or the other. I'm open to ideas.

oddlama commented 8 months ago

I've added a local storage mode now, which should provide a solution to that. One disadvantage I noticed with the local storage approach is that you must be a little more careful with public repositories. If one of your host keys is ever leaked, an attacker can access all rekeyed secrets in the repo and decrypt them. Not very likely, but something to keep in mind.

I've added an upgrade notice and readme section for the new mode. This issue went a bit offtopic, do you think the original question is resolved so this can be closed?

axelkar commented 8 months ago

If one of your host keys is ever leaked, an attacker can access all rekeyed secrets in the repo and decrypt them.

I didn't even think of that. Good catch!

axelkar commented 2 months ago

I've added a local storage mode now, which should provide a solution to that. One disadvantage I noticed with the local storage approach is that you must be a little more careful with public repositories. If one of your host keys is ever leaked, an attacker can access all rekeyed secrets in the repo and decrypt them. Not very likely, but something to keep in mind.

I mean secrets (excluding MAC addresses and other PII) can almost always be changed. Btw, does the tool encrypt only the required secrets for each machine? The impact could be minimal compared to someone getting root on a box.

Have you thought of different ways of distributing keys? Wihout Nix derivations?

This issue went a bit offtopic, do you think the original question is resolved so this can be closed?

Uhh actually I can't think of any way to still do this. I was taking inspiration from nixus