oddlama / agenix-rekey

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

Improve UX for multiple split `masterIdentities` #28

Closed Lukas-C closed 3 months ago

Lukas-C commented 3 months ago

Fixes #24.

Changes

First, the implementation introduces a new syntax for specifying identities in age.rekey.masterIdentities:

{
  # This has the same type as the old way to specify an identity.
  identity = ./path-to-identity;
  # Optional; This has the same type as `age.rekey.hostPubkey`
  # and allows explicit association of a pubkey with an identity.
  pubkey = "age1yubikey1<pubkey>";
}

The old syntax continues to be supported through automatic coercion into the new format. If pubkey is specified, it will be used to encrypt files, instead of trying to extract a pubkey from the identity file.

Second, the implementation may extract an "implicit" pubkey from the identity file to use instead of the identity itself. This will only happen if the following conditions are met:

Every identity file that does not match all of the above criteria will be passed to (r)age without further processing, in order to let the program itself deal with the identity at runtime.

Third, the implementation adds support for the new environment variable AGENIX_REKEY_PRIMARY_IDENTITY, which is used during decryption. If set to a pubkey, agenix-rekey will attempt to locate the key amongst the explicitly and implicitly specified pubkeys:

Implementation

I ended up writing a wrapper script for (r)age in ./nix/lib.nix that is shared between for the encrypt and decrypt phases and decides what phase to run based on the first argument it receives. The remaining arguments are directly passed to (r)age. Warnings are deferred to stderr in order to not mess with generators that use the decrypt command in a piping fashion, e.g.:

generator.script = "cat ${./secret.age} | ${decrypt}";

Testing

The current code can successfully handle the following flake.nix. See the comments next to the different masterIdentities and age.secrets for further details:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";

    agenix = {
      url = "github:ryantm/agenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    agenix-rekey = {
      #url = "github:oddlama/agenix-rekey";
      url = "github:Lukas-C/agenix-rekey";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = inputs@{ self, nixpkgs, agenix, agenix-rekey, ... }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
    in {
      nixosConfigurations."test" = nixpkgs.lib.nixosSystem {
        modules = [
          agenix.nixosModules.default
          agenix-rekey.nixosModules.default
          {
            nixpkgs = { inherit system; };

            age.rekey = {
              storageMode = "local";
              localStorageDir = ./rekey;

              masterIdentities = [
                # Yubikey A, not plugged in
                ./identities/yubikey-a.pub

                # Yubikey B, plugged in
                ./identities/yubikey-b.pub

                ## Yubikey B, multiple pubkeys for one identity.
                ## Confirmed to fail, since this is not supported.
                #./identities/yubikey-b-multiple-pubkey.pub

                # Unencrypted plain `age` identity with a wonky name.
                # Can be used as-is, since the identity does not require further action,
                # even though this configuration should not be used.
                (./identities + "/test -i key.key")

                # Encrypted plain `age` identity, without an available pubkey.
                # Prompts for the passphrase, during both encryption and decryption.
                ./identities/testkeypass.key

                # (Same) encrypted identity with a pubkey.
                # Would only prompt for the passphrase during decryption.
                {
                  identity = ./identities/testkeypass.key;
                  pubkey = "age1w0reymggaakqgck6d8ndy635deh8p7eavaskpz0u48yqlad8v55qqum065";
                }
              ];
            };

            age.secrets = {
              # Basic secret file created with `agenix edit`.
              test = { rekeyFile = ./test.txt.age; };

              # Basic generated file to test `agenix generate`.
              test-generate = {
                rekeyFile = ./generate.age;
                generator.script = { ... }: "echo generator";
              };

              # Test proper behavior when piping into the `decrypt` command,
              # which is now a wrapper script around (r)age.
              # Requires setting AGENIX_REKEY_PRIMARY_IDENTITY to an invalid pubkey to test properly.
              # The resulting warning should not appear in the file as it is directed to stderr instead.
              test-generate-stdio = {
                rekeyFile = ./generate-stdio.age;
                generator.script = { decrypt, ... }: ''
                  cat ${./generate.age} | ${decrypt}
                '';
              };
            };
          }
        ];
      };

      agenix-rekey = agenix-rekey.configure {
        userFlake = self;
        nodes = self.nixosConfigurations;
      };

      devShells."x86_64-linux".default = pkgs.mkShell {
        packages = [ agenix-rekey.packages."x86_64-linux".default ];
      };
    };
}

AGENIX_REKEY_PRIMARY_IDENTITY set to pubkey of Yubikey B

AGENIX_REKEY_PRIMARY_IDENTITY unset

AGENIX_REKEY_PRIMARY_IDENTITY set to invalid pubkey

Behavior is the same as if unset. The following warning is printed at least once for every one of the three operations:

warning: Environment variable AGENIX_REKEY_PRIMARY_IDENTITY is set, but matches none of the pubkeys found by agenix-rekey.
warning: Please check that your pubkeys and identities are set up correctly.
warning: Value of AGENIX_REKEY_PRIMARY_IDENTITY: "test"
warning: Pubkeys found:
warning:   age1yubikey1<pubkey-a> for file "/nix/store/<hash>-source/identities/yubikey-a.pub"
warning:   age1yubikey1<pubkey-b> for file "/nix/store/<hash>-source/identities/yubikey-b.pub"
warning:   age1<pubkey> for file "/nix/store/<hash>-source/identities/testkeypass.key"

TODO

Lukas-C commented 3 months ago

I was originally considering warnings that indicate a "potentially degraded" user experience, e.g. a warning if a Yubikey identity does not have an associated pubkey. However I think now that a proper explanation in the manual/readme is required anyways and will be sufficient. No need to create additional complexity where there doesn't have to be.

So if you are happy with the current state, I will only add the remaining documentation and leave the PR otherwise as is.

Lukas-C commented 3 months ago

I have now expanded the README and hope the explanations are clear enough so that people know what is possible, what to do and where to look.

Minor remarks:

oddlama commented 3 months ago

I have now expanded the README and hope the explanations are clear enough so that people know what is possible, what to do and where to look.

Looks good, thanks.

Minor remarks:

* I had to omit some of the details of the new `masterIdentities` type signature, since readability otherwise would have suffered even more. Instead I made due with a link to the actual lines of code. At the moment it points to the location where the corresponding lines should end up after the merge, so we should probably update this to a proper permalink afterwards.

I'll probably switch to mdbook rendered documentation at some point, then it will hopefully be more readable.

* I added a separate section for the new environment variable (and potential future ones). Since we're somehow still missing a terminal prompt emoji in the Unicode standard, I made due with a keyboard (⌨) as the section logo.

Good idea!

I think this is good to now, thanks again for all your work!

Lukas-C commented 3 months ago

Happy to contribute, thank you for your openness towards my suggestions and for the constructive discussion and feedback :D