Vaultix

Secret management for NixOS.

This project is highly inspired by agenix-rekey and sops-nix. Based on rust age crate.

  • Support Template
  • Age Plugin Compatible
  • Support PIV Card (Yubikey)
  • Support identity with passphrase
  • Compatible with userborn module option
  • No Bash

Prerequisits

It basically require:

  • nix-command feature enabled
  • flake-parts structured config
  • self as one of specialArgs for nixosSystem
  • systemd.sysusers or services.userborn option enabled

See following for reason and details.


enable nix-command flakes features

Which is almost every user will do.

Vaultix depends on nix flake and nix apps to perform basic function.

nix.settings = {
  experimental-features = [
    "nix-command"
    "flakes"
  ];
}

flake-parts structured config

flake-parts provides modulized flake config, vaultix using flake module to produce nix apps and hidding complexity.


self as one of specialArgs for nixosSystem

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.


enable systemd.sysusers or services.userborn

sysusers was comes with Perlless Activation.

userborn was introduced in Aug 30 2024

Vaultix using systemd instead of old perl script for activating on system startup or switch. It meams that you need on nixos-24.05 or newer version for using it.

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.oluceps.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;
}
# ...

flakeModule Options

The Vaultix configuration option has two parts: in flake level and nixos module level. You need to set both to make it work.

Here is a flake module configuration, it should be written in your flake top-level or in flake module.

Commented options means its default value.

You could find the definition here

flake.vaultix = {
  nodes = self.nixosConfigurations;
  identity = "/where/sec/age-yubikey-identity-7d5d5540.txt.pub";
  # extraRecipients = [ ];
  # cache = "./secrets/cache";
};

node =

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 convertion, there always some way. For example, for colmena:

nodes = inherit ((colmena.lib.makeHive self.colmena).introspect (x: x)) nodes;

identity =

Age identity file.

Supports age native secrets (recommend protected with passphrase), this could be a:

  • string, 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 writting path directly will copy your secrets into nix store, with Global READABLE.

This is THE identity that could decrypt all of your secret, take care of it.

Every path in your nix configuration will load file to nix store, eventually shows as string of absolute path to nix store.

Since it inherited great compatibility of age, you could use yubikey. Feel free to test other plugins like age tpm.

extraRecipients =

Recipients used for backup. Any of identity of them will able to decrypt all secrets, like the identity.

Changing this will not take effect to renc command output. The hash of host pub key re-encrypted filename is blake3(encrypted secret content + host public key).

cache =

String of path that relative to flake root, used for storing host public key re-encrypted secrets. It's default ./secrets/cache.

NixOS Module Options

Configurable option could be divided into 3 parts:

# configuration.nix
{
  imports = [ inputs.vaultix.nixosModules.default ];
  vaultix = {
    settings = { ... };
    secrets = { ... };
    templates = { ... };
    needByUser = [...];
  };
}

Settings =

Literally.

decryptedDir: path str

Folder where secrets are symlinked to. Default is /run/vaultix.

decryptedDirForUser: path str

Same as above, but for secrets and templates that required by user, which means needs to be initialize before user born.

decryptedMountPoint: path str with no trailing slash

default is /run/vaultix.d

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

{ path: str, type: str }

default is 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: str

ssh public key of the private key, which defines above. This is different from every host, since each generates host key while initial booting.

Get this by: ssh-keyscan ip. It supports ed25519 type.


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: path str

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: str

To insert secrets in this string, insert config.vaultix.placeholder.example.

This pretend the secret which id (the keyof attribute of secrets) was defined.

secrets = {
  # the id is 'example'. despite `name`.
  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
''

TO BE NOTICE that the source secret file may have trailing \n:

trim: bool

default true;

Removing trailing and leading whitespace by default.

needByUser: [str]

For deploying secrets and templates that required before user init.

List of id of templates or secrets.

Nix Apps

Provided user friendly cli tools:

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

Workflows

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. Run renc:

nix run .#vaultix.app.x86_64-linux.renc

4. Add new 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

Then add changes to git.

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.

/|、
(˙、.7
|、~ヽ
じしf_,)ノ

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 to your REAL private key location (not bind mounted or symlinked etc.)