age-encrypted secrets for NixOS
Find a file
2023-01-29 10:44:19 -08:00
.github/workflows ci: split linux and macos 2021-11-20 11:39:24 -08:00
contrib contrib: fix _incr_version script 2022-09-25 14:22:15 -07:00
example dev: add integration test 2021-05-09 14:22:48 -07:00
example_keys add README and examples 2020-09-03 13:16:44 -07:00
modules feature: combine root and nonroot secret install; delay chowning 2022-07-10 11:47:58 -07:00
pkgs version 0.13.0 2022-09-25 14:22:43 -07:00
test test: fix type error 2023-01-09 11:25:24 +01:00
.gitignore chore: add nix build result path to gitignore 2022-10-15 12:10:02 -04:00
default.nix add flake and default .nix files; add agenix command 2020-09-03 11:24:33 -07:00
flake.lock feature: update flake output format and docs 2023-01-29 10:44:19 -08:00
flake.nix feature: update flake output format and docs 2023-01-29 10:44:19 -08:00
LICENSE initial prototype 2020-08-31 21:37:26 -07:00
overlay.nix Update overlay.nix 2020-12-30 16:18:38 -05:00
README.md feature: update flake output format and docs 2023-01-29 10:44:19 -08:00

agenix - age-encrypted secrets for NixOS

agenix is a commandline tool for managing secrets encrypted with your existing SSH keys. This project also includes the NixOS module age for adding encrypted secrets into the Nix store and decrypting them.

Contents

Problem and solution

All files in the Nix store are readable by any system user, so it is not a suitable place for including cleartext secrets. Many existing tools (like NixOps deployment.keys) deploy secrets separately from nixos-rebuild, making deployment, caching, and auditing more difficult. Out-of-band secret management is also less reproducible.

agenix solves these issues by using your pre-existing SSH key infrastructure and age to encrypt secrets into the Nix store. Secrets are decrypted using an SSH host private key during NixOS system activation.

Features

  • Secrets are encrypted with SSH keys
  • No GPG
  • Very little code, so it should be easy for you to audit
  • Encrypted secrets are stored in the Nix store, so a separate distribution mechanism is not necessary

Notices

  • Password-protected ssh keys: since the underlying tool age/rage do not support ssh-agent, password-protected ssh keys do not work well. For example, if you need to rekey 20 secrets you will have to enter your password 20 times.

Installation

Choose one of the following methods:

Install via niv

First add it to niv:

$ niv add ryantm/agenix

Install module via niv

Then add the following to your configuration.nix in the imports list:

{
  imports = [ "${(import ./nix/sources.nix).agenix}/modules/age.nix" ];
}

Install CLI via niv

To install the agenix binary:

{
  environment.systemPackages = [ (pkgs.callPackage "${(import ./nix/sources.nix).agenix}/pkgs/agenix.nix" {}) ];
}

Install via nix-channel

As root run:

$ sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix
$ sudo nix-channel --update

Install module via nix-channel

Then add the following to your configuration.nix in the imports list:

{
  imports = [ <agenix/modules/age.nix> ];
}

Install CLI via nix-channel

To install the agenix binary:

{
  environment.systemPackages = [ (pkgs.callPackage <agenix/pkgs/agenix.nix> {}) ];
}

Install via fetchTarball

Install module via fetchTarball

Add the following to your configuration.nix:

{
  imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix" ];
}

or with pinning:

{
  imports = let
    # replace this with an actual commit id or tag
    commit = "298b235f664f925b433614dc33380f0662adfc3f";
  in [
    "${builtins.fetchTarball {
      url = "https://github.com/ryantm/agenix/archive/${commit}.tar.gz";
      # update hash from nix build output
      sha256 = "";
    }}/modules/age.nix"
  ];
}

Install CLI via fetchTarball

To install the agenix binary:

{
  environment.systemPackages = [ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {}) ];
}

Install via Flakes

Install module via Flakes

{
  inputs.agenix.url = "github:ryantm/agenix";
  # optional, not necessary for the module
  #inputs.agenix.inputs.nixpkgs.follows = "nixpkgs";

  outputs = { self, nixpkgs, agenix }: {
    # change `yourhostname` to your actual hostname
    nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
      # change to your system:
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        agenix.nixosModules.default
      ];
    };
  };
}

Install CLI via Flakes

You don't need to install it,

nix run github:ryantm/agenix -- --help

but, if you want to (change the system based on your system):

{
  environment.systemPackages = [ agenix.packages.x86_64-linux.default ];
}

Tutorial

  1. The system you want to deploy secrets to should already exist and have sshd running on it so that it has generated SSH host keys in /etc/ssh/.

  2. Make a directory to store secrets and secrets.nix file for listing secrets and their public keys (This file is not imported into your NixOS configuration. It is only used for the agenix CLI.):

    $ mkdir secrets
    $ cd secrets
    $ touch secrets.nix
    
  3. Add public keys to secrets.nix file (hint: use ssh-keyscan or GitHub (for example, https://github.com/ryantm.keys)):

    let
      user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH";
      user2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI6jSq53F/3hEmSs+oq9L4TwOo1PrDMAgcA1uo1CCV/";
      users = [ user1 user2 ];
    
      system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE";
      system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1";
      systems = [ system1 system2 ];
    in
    {
      "secret1.age".publicKeys = [ user1 system1 ];
      "secret2.age".publicKeys = users ++ systems;
    }
    
  4. Edit secret files (these instructions assume your SSH private key is in ~/.ssh/):

    $ agenix -e secret1.age
    
  5. Add secret to a NixOS module config:

    {
      age.secrets.secret1.file = ../secrets/secret1.age;
    }
    
  6. Use the secret in your config:

    {
      users.users.user1 = {
        isNormalUser = true;
        passwordFile = config.age.secrets.secret1.path;
      };
    }
    
  7. NixOS rebuild or use your deployment tool like usual.

    The secret will be decrypted to the value of config.age.secrets.secret1.path (/run/agenix/secret1 by default).

Reference

age module reference

age.secrets

age.secrets attrset of secrets. You always need to use this configuration option. Defaults to {}.

age.secrets.<name>.file

age.secrets.<name>.file is the path to the encrypted .age for this secret. This is the only required secret option.

Example:

{
  age.secrets.monitrc.file = ../secrets/monitrc.age;
}

age.secrets.<name>.path

age.secrets.<name>.path is the path where the secret is decrypted to. Defaults to /run/agenix/<name> (config.age.secretsDir/<name>).

Example defining a different path:

{
  age.secrets.monitrc = {
    file = ../secrets/monitrc.age;
    path = "/etc/monitrc";
  };
}

For many services, you do not need to set this. Instead, refer to the decryption path in your configuration with config.age.secrets.<name>.path.

Example referring to path:

{
  users.users.ryantm = {
    isNormalUser = true;
    passwordFile = config.age.secrets.passwordfile-ryantm.path;
  };
}
builtins.readFile anti-pattern
{
  # Do not do this!
  config.password = builtins.readFile config.age.secrets.secret1.path;
}

This can cause the cleartext to be placed into the world-readable Nix store. Instead, have your services read the cleartext path at runtime.

age.secrets.<name>.mode

age.secrets.<name>.mode is permissions mode of the decrypted secret in a format understood by chmod. Usually, you only need to use this in combination with age.secrets.<name>.owner and age.secrets.<name>.group

Example:

{
  age.secrets.nginx-htpasswd = {
    file = ../secrets/nginx.htpasswd.age;
    mode = "770";
    owner = "nginx";
    group = "nginx";
  };
}

age.secrets.<name>.owner

age.secrets.<name>.owner is the username of the decrypted file's owner. Usually, you only need to use this in combination with age.secrets.<name>.mode and age.secrets.<name>.group

Example:

{
  age.secrets.nginx-htpasswd = {
    file = ../secrets/nginx.htpasswd.age;
    mode = "770";
    owner = "nginx";
    group = "nginx";
  };
}

age.secrets.<name>.group

age.secrets.<name>.group is the name of the decrypted file's group. Usually, you only need to use this in combination with age.secrets.<name>.owner and age.secrets.<name>.mode

Example:

{
  age.secrets.nginx-htpasswd = {
    file = ../secrets/nginx.htpasswd.age;
    mode = "770";
    owner = "nginx";
    group = "nginx";
  };
}

age.secrets.<name>.symlink is a boolean. If true (the default), secrets are symlinked to age.secrets.<name>.path. If false, secerts are copied to age.secrets.<name>.path. Usually, you want to keep this as true, because it secure cleanup of secrets no longer used. (The symlink will still be there, but it will be broken.) If false, you are responsible for cleaning up your own secrets after you stop using them.

Some programs do not like following symlinks (for example Java programs like Elasticsearch).

Example:

{
  age.secrets."elasticsearch.conf" = {
    file = ../secrets/elasticsearch.conf.age;
    symlink = false;
  };
}

age.secrets.<name>.name

age.secrets.<name>.name is the string of the name of the file after it is decrypted. Defaults to the <name> in the attrpath, but can be set separately if you want the file name to be different from the attribute name part.

Example of a secret with a name different from its attrpath:

{
  age.secrets.monit = {
    name = "monitrc";
    file = ../secrets/monitrc.age;
  };
}

age.ageBin

age.ageBin the string of the path to the age binary. Usually, you don't need to change this. Defaults to rage/bin/rage.

Overriding age.ageBin example:

{pkgs, ...}:{
    age.ageBin = "${pkgs.age}/bin/age";
}

age.identityPaths

age.identityPaths is a list of paths to recipient keys to try to use to decrypt the secrets. All of the file paths must be present, but only one needs to be able to decrypt the secret. Usually, you don't need to change this. By default, this is the rsa and ed25519 keys in config.services.openssh.hostKeys.

Overriding age.identityPaths example:

{
    age.identityPaths = [ "/var/lib/persistent/ssh_host_ed25519_key" ];
}

age.secretsDir

age.secretsDir is the directory where secrets are symlinked to by default.Usually, you don't need to change this. Defaults to /run/agenix.

Overriding age.secretsDir example:

{
    age.secretsDir = "/run/keys";
}

age.secretsMountPoint

age.secretsMountPoint is the directory where the secret generations are created before they are symlinked. Usually, you don't need to change this. Defaults to /run/agenix.d.

Overriding age.secretsMountPoint example:

{
    age.secretsMountPoint = "/run/secret-generations";
}

agenix CLI reference

agenix -e FILE [-i PRIVATE_KEY]
agenix -r [-i PRIVATE_KEY]

options:
-h, --help                show help
-e, --edit FILE           edits FILE using $EDITOR
-r, --rekey               re-encrypts all secrets with specified recipients
-i, --identity            identity to use when decrypting
-v, --verbose             verbose output

FILE an age-encrypted file

PRIVATE_KEY a path to a private SSH key used to decrypt file

EDITOR environment variable of editor to use when editing FILE

RULES environment variable with path to Nix file specifying recipient public keys.
Defaults to './secrets.nix'

Rekeying

If you change the public keys in secrets.nix, you should rekey your secrets:

$ agenix --rekey

To rekey a secret, you have to be able to decrypt it. Because of randomness in age's encryption algorithms, the files always change when rekeyed, even if the identities do not. (This eventually could be improved upon by reading the identities from the age file.)

Overriding age binary

The agenix CLI uses rage by default as its age implemenation, you can use the reference implementation age with Flakes like this:

{pkgs,agenix,...}:{
  environment.systemPackages = [
    (agenix.packages.x86_64-linux.default.override { ageBin = "${pkgs.age}/bin/age"; })
  ];
}

Community and Support

Support and development discussion is available here on GitHub and also through Matrix.

Threat model/Warnings

This project has not been audited by a security professional.

People unfamiliar with age might be surprised that secrets are not authenticated. This means that every attacker that has write access to the secret files can modify secrets because public keys are exposed. This seems like not a problem on the first glance because changing the configuration itself could expose secrets easily. However, reviewing configuration changes is easier than reviewing random secrets (for example, 4096-bit rsa keys). This would be solved by having a message authentication code (MAC) like other implementations like GPG or sops have, however this was left out for simplicity in age.

Acknowledgements

This project is based off of sops-nix created Mic92. Thank you to Mic92 for inspiration and advice.