Vaultix
Secret Manage Scheme for NixOS
This project is highly inspired by agenix-rekey and sops-nix.
- Based on age rust implementation
- Parallel encryption at host granularity
- Support secure identity with passphrase
- Support template for reusing insensitive stanza
- Support Yubikey PIV with age-yubikey-plugin
- Small closure size increase1
- Fits well with new
sysuser
nixos userborn machenism2 - Design with flake-parts and modulized flake
- Written in Rust for speed, safety, and simplicity
- Compatible and tested with common3 nixos deployment tools
nix build result on Nov 19 2024, 1465128 bytes.
nixos-rebuild, apply, colmena was confirmed supported
Prerequisites
- use
flake
nix-command
andflake
experimental feature enabled.inputs
orself
as one ofspecialArgs
fornixosSystem
systemd.sysusers
orservices.userborn
option enabled (means you need NixOS 24.11 or newer)
enable
nix-command
flakes
features
Which almost user done.
Vaultix depends on flake and nix apps perform basic function.
nix.settings = {
experimental-features = [
"nix-command"
"flakes"
];
}
inputs
orself
as one ofspecialArgs
fornixosSystem
For passing top-level flake arguments to nixos module.
This requirement may change in the future, with backward compatiblility. Looking forward for a better implementation in nixpkgs that more gracefully to do so.
e.g.
{
description = "An Example";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
vaultix.url = "github:milieuim/vaultix";
};
outputs = { self, nixpkgs, ... }@inputs: {
nixosConfigurations.my-nixos = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
######################################
specialArgs = {
inherit self; # or inputs. You can inherit both as well.
};
######################################
modules = [
inputs.vaultix.nixosModules.default
./configuration.nix
];
# ...
};
# vaultix = ...
};
}
enable
systemd.sysusers
orservices.userborn
sysusers
was comes with Perlless Activation.
userborn
was introduced in Aug 30 2024
Both available in NixOS 24.11 or newer.
Vaultix using systemd instead of old perl script for activating on system startup or switch.
setup
You could also find the minimal complete nixos configuration on CI VM test.
Layout Preview
{
withSystem,
self,
inputs,
...
}:
{
flake = {
vaultix = {
nodes = self.nixosConfigurations;
identity = "/home/who/key";
};
nixosConfigurations.host-name = withSystem "x86_64-linux" ({ system, ... }:
inputs.nixpkgs.lib.nixosSystem (
{
inherit system;
specialArgs = {
inherit self; # Required
};
modules = [
inputs.milieuim.nixosModules.vaultix # import nixosModule
(
{ config, ... }:
{
services.userborn.enable = true; # or systemd.sysuser, required
vaultix = {
settings.hostPubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEu8luSFCts3g367nlKBrxMdLyOy4Awfo5Rb397ef2BC";
secrets.test-secret-1 = {
file = ./secrets/there-is-a-secret.age;
};
};
}
)
./configuration.nix
];
}
)
);
};
}
And you will be able to use secret on any module with, e.g.:
{
services.proxy1.environmentFile = config.vaultix.secrets.example.path;
}
# ...
flake Configuration
The Vaultix configuration takes into two parts:
-
flake level setup.
You can choose either flakeModule or pure nix in this part. it's recommended to use
flakeModule
, since it provides type check and more elegant configuration interface. -
nixos module level setup.
It's required to complete setup in both part to make it work.
flakeModule Options
note
If you don't like flake-parts, you could skip to another choice without flake-level option type check: pure nix
This is a flake module configuration, it should be written in your flake top-level or in flake module.
You could find the full definition here
flake.vaultix = {
nodes = self.nixosConfigurations;
identity = "/somewhere/age-yubikey-identity-deadbeef.txt";
# extraRecipients = [ ]; # default
# cache = "./secrets/cache"; # default
};
nodes
- type:
typeOf nixosConfigurations
NixOS systems that allow vaultix to manage. Generally pass self.nixosConfigurations
will work, if you're using framework like colmena
that produced unstandard system outputs, you need manually conversion, there always some way. For example, for colmena
:
nodes = inherit ((colmena.lib.makeHive self.colmena).introspect (x: x)) nodes;
identity
- type:
string or path
Age identity file
.
Supports age native secrets (recommend protected with passphrase), this could be a:
-
string (Recommend), of absolute path to your local age identity. Thus it can avoid loading identity to nix store.
-
path, relative to your age identity in your configuration repository. Note that writing path directly will copy your private key into nix store, with Global READABLE.
caution
Writing path directly (without "
) will copy your private key into local nix store, with Global READABLE. Set path is safe only while your private key cannot be directly accessed, such as storing in yubikey or complex passphrase protected.
This is the identity that could decrypt all of your secret, take care of it.
Every
path
type variable in your nix configuration will load file to nix store, eventually shows as string of absolute path to nix store.
example:
"/somewhere/age-yubikey-identity-7d5d5540.txt.pub" # note that is string,
# or your eval will be impure.
./age-yubikey-identity-7d5d5540.txt.pub
"/somewhere/age-private-key"
The Yubikey PIV identity with plugin provided better security, but the decryption speed (at re-encryption and edit stage) will depend on your yubikey device.
Since it inherited great compatibility of age
, you could use yubikey. Feel free to test other plugins like age tpm.
extraRecipients
- type:
list of string
Recipients used for backup. Any of identity of them will able to decrypt all secrets, like the identity
.
cache
String of path that relative to flake root, used for storing host public key
re-encrypted secrets. It's default ./secrets/cache
.
In this way your configuration will looks like:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
vaultix.url = "github:milieuim/vaultix";
};
outputs =
inputs@{
flake-parts,
vaultix,
self,
...
}:
flake-parts.lib.mkFlake { inherit inputs; } ({ ... }:
{
flake = {
vaultix = {
nodes = self.nixosConfigurations;
identity = "/somewhere/some";
cache = "./secrets/cache";
};
nixosConfigurations = {
tester = withSystem "x86_64-linux" ({system,...}:
with inputs.nixpkgs;
lib.nixosSystem {
inherit system;
specialArgs = {
inherit self; # or..
};
modules = [
./configuration.nix
];
}
);
};
};
});
}
Pure Nix Configuration
note
If you completed setup with flake-parts' flakeModule, you can skip this section and jump to nixos module setup
The option is identical to flakeModule, but different way to perform nix app producing.
vaultix = vaultix.configure {
localFlake = self; # different from flakeModule way
# identical
nodes = self.nixosConfigurations;
identity = self + "/age-yubikey-identity-deadbeef.txt.pub";
extraRecipients = [ ];
cache = "./secret/.cache";
};
In this way your flake will looks like:
{
description = "An Example";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
vaultix.url = "github:milieuim/vaultix";
};
outputs = { self, nixpkgs, ... }@inputs: {
nixosConfigurations.my-nixos = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit self;
};
modules = [
inputs.vaultix.nixosModules.default
./configuration.nix
];
# ...
};
vaultix = vaultix.configure {
localFlake = self; # different from flakeModule way
# identical
nodes = self.nixosConfigurations;
identity = self + "/age-yubikey-identity-deadbeef.txt.pub";
extraRecipients = [ ];
cache = "./secret/.cache";
};
};
}
NixOS Module Options
This is in nixosConfiguration, produced by nixosSystem
function. Any confusion about this please refer to our test system config and unofficial doc
Configurable option could be divided into 3 parts:
# in configuration.nix etc.
{
imports = [ inputs.vaultix.nixosModules.default ];
vaultix = {
settings = {
hostPubkey = "ssh-ed25519 AAAA..."; # required
# ...
};
secrets = { };
templates = { };
beforeUserborn = [ ];
};
}
Settings
Literally.
decryptedDir
- type:
string of absolute path
- default:
/run/vaultix
Folder where secrets are symlinked to.
decryptedDirForUser
- type:
string of absolute path
- default:
/run/vaultix-for-user
Same as above, but for secrets and templates that required by user, which means needs to be initialize before user born.
decryptedMountPoint
- type:
string of absolute path
- default:
/run/vaultix.d
Path str with no trailing slash
Where secrets are created before they are symlinked to vaultix.settings.decryptedDir
Vaultix use this machenism to implement atomic manage, like other secret managing schemes.
It decrypting secrets into this directory, with generation number like /run/vaultix.d/1
, then symlink it to decryptedDir
.
hostKeys
- type:
{ path: str, type: str }
- default:
config.services.openssh.hostKeys
This generally has no need to manually change, unless you know clearly what you're doing.
Ed25519 host private ssh key (identity) path that used for decrypting secrets while deploying.
format:
[
{
path = "/path/to/ssh_host_ed25519_key";
type = "ed25519";
}
]
hostPubkey
- type:
(string of pubkey) or (path of pubkey file)
example:
hostPubkey = "ssh-ed25519 AAAAC3Nz....."
# or
hostPubkey = ./ssh_host_ed25519_key.pub # no one like this i think
ssh public key of the hostKey. This is different from every host, since each generates host key while initial booting.
Get this of remote machine by: ssh-keyscan ip
. It supports ed25519
type.
You could find it in /etc/ssh/
next to host ssh private key, with .pub
suffix.
This could be either literal string or path, the previous one is more recommended.
Secrets
Here is a secrets:
secrets = {
example = {
file = ./secret/example.age;
};
};
The secret is expected to appear in /run/vaultix/
with 0400
and own by uid0.
Here is full options that configurable:
secrets = {
example = {
file = ./secret/example.age;
mode = "640"; # default 400
owner = "root";
group = "users";
name = "example.toml";
path = "/some/place";
};
};
This part basically keeps identical with agenix
. But has few diffs:
- no
symlink: bool
option, since it has an systemd function called tmpfiles.d.
path
- type:
absolute path string
If you manually set this, it will deploy to specified location instead of to /run/vaultix.d
(default value of decryptedMountPoint).
If you still set the path to directory to /run/vaultix
(default value of decryptedDir), you will receive a warning, because you should use the name
option instead of doing that.
Templates
Vaultix
provides templating function. This makes it able to insert secrets content into plaintext config while deploying.
Overview of this option:
templates = {
test-template = {
name = "template.txt";
content = "this is a template for testing ${config.vaultix.placeholder.example}";
trim = true;
# permission options like secrets
mode = "640"; # default 400
owner = "root";
group = "users";
name = "example.toml";
path = "/some/place";
};
}
content
Insert config.vaultix.placeholder.example
in plain string content.
This expects the placeholder.<*>
identical with defined secret id
(the keyof it).
secrets = {
# the id is 'example' here.
example = {
file = ./secret/example.age;
};
};
The content could also be multiline:
''
this is a template for testing ${config.vaultix.placeholder.example}
this is another ${config.vaultix.placeholder.what}
${config.vaultix.placeholder.some} here
''
note
Source secret text may have trailing \n
, if you don't want automatically remove it please see trim:
trim
- type:
bool
- default:
true
;
Removing trailing and leading whitespace by default.
beforeUserborn
- type:
list of string
For deploying secrets and templates that required before user init.
List of id of templates or secrets.
example:
beforeUserborn = ["secret1" "secret2" "template1"];
Nix Apps
Provided user friendly cli interface, feel free to set shell alias:
renc
This is needed every time the host key or secret content changed.
The wrapped vaultix will decrypt cipher content to plaintext and encrypt it with target host public key, finally stored in cache
.
nix run .#vaultix.app.x86_64-linux.renc
edit
This will decrypt and open file with $EDITOR
. Will encrypt it after editing finished.
nix run .#vaultix.app.x86_64-linux.edit -- ./secrets/some.age
Cheat Sheets
Common used workflow with vaultix.
Add new secret
1. Run edit:
nix run .#vaultix.app.x86_64-linux.edit -- ./where/new-to-add.age
2. Add a secret to nixos module:
secrets = {
#...
+ new-to-add.file = ./where/new-to-add.age;
};
3. Add it to git
4. Run renc:
nix run .#vaultix.app.x86_64-linux.renc
4. Add all produced stuff to git.
Modify existed secret
nix run .#vaultix.app.x86_64-linux.edit -- ./where/to-edit.age
nix run .#vaultix.app.x86_64-linux.renc
Then add changes to git.
Remove secret
secrets = {
#...
- new-to-add.file = ./where/new-to-add.age;
};
rm ./where/new-to-add.age
nix run .#vaultix.app.x86_64-linux.renc
Development
DevShell
nix develop
Test
For testing basic functions with virtual machine:
nix run github:nix-community/nixos-anywhere -- --flake .#tester --vm-test
Run full test with just full-test
Format
This repo follows nixfmt-rfc-style
style, reformat with running nixfmt .
.
Lint
Lint with statix.
test coverage
Unit tests
- template parser
- template render
- re-encrypted content integrity
Virtual machine tests
- extract regular secret
- extract regular secret/template
- deploy with empty secret and template
- integrity of extraction secret content
- extract before userborn secret/template
- integrity of before userborn secret/template extraction
Advanced
Bootstrap
Vaultix relies on host ssh key controlling per-host secret access permission, which generated when each host first boot.
You could bootstrap the host with nixos-anywhere with --copy-host-keys, then optionally regenerate the host key after successfully boot. Or first deploy without vaultix.
Tricks
In most cases you don't need these.
Manually deploy
This must be executed on local, and be sure all secrets re-encrypted before that, since there has no module to guarantee it in this case.
Manually deploy not affect next vaultix activation. It's a trick that helps you finish deploy while your flake options of vaultix broken:
This eval nixos vaultix configs to json.
nix eval .#nixosConfigurations.your-hostname.config.vaultix-debug --json > profile.json
So that you can feed it to vaultix cli directly:
nix run github:milieuim/vaultix -- -p ./profile.json deploy
To be notice that deploy secrets that needs to be extracted before user init (deploy with --early) in this way is meaningless.
Threat Model
The Project based on age, inherited age thread model. See age spec.
Vaultix ensures that your plaintext secrets are never stored in the Nix store with globally readable permissions or written to disk, while also securing them during network transmission.
About "Harvest Now, Decrypt Later"
The Harvest Now, Decrypt Later strategy involves collecting and storing encrypted files with the aim of decrypting them in the future, potentially using quantum computers.
If your configuration is exposed in a public repository, Vaultix—like most other NixOS secret management solutions—cannot fully mitigate this risk. For more context, see this issue and discussion.
For those concerned about this threat, consider using age-plugin-sntrup761x25519, which offers post-quantum encryption. This plugin relies on Rust bindings for C implementations of cryptographic algorithms from the NIST Post-Quantum Cryptography competition. However, it’s important to note that this solution has not undergone extensive security review.
Frequent Asked Questions
Q. Rebooting and unit failed with could not found ssh private key, but it indeed just there.
A. Check if using root on tmpfs
, and modify hostKeys path to Absolute path string which your REAL private key located (not bind mounted or symlinked etc.). You could also choose setting needForBoot
for your persist mountpoing. This could also fix similar issue happened with agenix and sops-nix.
Q. Why another secret management solution for NixOS?
A. Because I don't like Bash, which most solutions rely on. Plus, many lack templating features, and sops-nix feels too bloated for my needs.
- i18n & multilingual docs
- restart/reload sd unit control (after systemd varlink api)
- nix without framework compatible
-
parallel encryption &
decryption(age identity notSend
) - reduce duplicated reads
- secrets for users (pre-userborn extraction)
- optimize template placeholder map get
- test with os
- deploy to specified location
- move storageInStore into flake module
- impl template
- [edit] or [add] secret with extra encrypt key
- check command and corresponding nix infra
- vaultix.d in ramfs
- vaultix{,.d} permission set
- age plugin support
- [renc] calc hash and skip unchanged
-
apply
Secret
metadata - nix integration
-
remote machine, compare hash, needs host priv key -
feed the toml after renced, thus store path changed -
eval in vaultix to json, reduce requirement