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
1

nix build result on Nov 19 2024, 1465128 bytes.

2

See merged pr 270727 and 332719

3

nixos-rebuild, apply, colmena was confirmed supported

Prerequisites

  • use flake
  • nix-command and flake experimental feature enabled.
  • inputs or self as one of specialArgs for nixosSystem
  • systemd.sysusers or services.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 or 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.

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 or services.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 not Send)
  • 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