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.
- Problem and solution
- Features
- Installation
- Tutorial
- Reference
- Community and Support
- Threat model/Warnings
- Acknowledgements
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.
- Secrets are encrypted with SSH keys
- system public keys via
ssh-keyscan
- can use public keys available on GitHub for users (for example, https://github.com/ryantm.keys)
- system public keys via
- 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
- 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.
Choose one of the following methods:
- niv (Current recommendation)
- nix-channel
- fetchTarball
- flakes
Install via niv
First add it to niv:
$ niv add ryantm/agenix
Then add the following to your configuration.nix
in the imports
list:
{
imports = [ "${(import ./nix/sources.nix).agenix}/modules/age.nix" ];
}
To install the agenix
binary:
{
environment.systemPackages = [ (pkgs.callPackage "${(import ./nix/sources.nix).agenix}/pkgs/agenix.nix" {}) ];
}
As root run:
$ sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix
$ sudo nix-channel --update
Then add the following to your configuration.nix
in the imports
list:
{
imports = [ <agenix/modules/age.nix> ];
}
To install the agenix
binary:
{
environment.systemPackages = [ (pkgs.callPackage <agenix/pkgs/agenix.nix> {}) ];
}
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";
# replace this with an actual hash
sha256 = "0000000000000000000000000000000000000000000000000000";
}}/modules/age.nix"
];
}
To install the agenix
binary:
{
environment.systemPackages = [ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {}) ];
}
{
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.nixosModule
];
};
};
}
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.defaultPackage.x86_64-linux ];
}
-
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/
. -
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 theagenix
CLI.):$ mkdir secrets $ cd secrets $ touch secrets.nix
-
Add public keys to
secrets.nix
file (hint: usessh-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; }
-
Edit secret files (these instructions assume your SSH private key is in ~/.ssh/):
$ agenix -e secret1.age
-
Add secret to a NixOS module config:
{ age.secrets.secret1.file = ../secrets/secret1.age; }
-
Use the secret in your config:
{ users.users.user1 = { isNormalUser = true; passwordFile = config.age.secrets.secret1.path; }; }
-
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).
age.secrets
attrset of secrets. You always need to use this
configuration option. Defaults to {}
.
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
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;
};
}
{
# 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
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
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
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
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
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
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
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
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 -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'
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.)
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.defaultPackage.x86_64-linux.override { ageBin = "${pkgs.age}/bin/age"; })
];
}
Support and development discussion is available here on GitHub and also through Matrix.
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
.
This project is based off of sops-nix created Mic92. Thank you to Mic92 for inspiration and advice.