Thoughts about uniform secrets integration in NixOS modules

When managing secrets inside of NixOS, one really unfortunate side-effect is that you very often kind of have to ad-hoc plumb everything through yourself, including possibly templating your secrets into configuration files, manually determining how to give them the correct privileges, etc. This is unfortunate, though, since I think it would be totally possible to make NixOS modules handle a lot more of the aspects of templating and plumbing secret values into runtime configuration.

I’m guessing people have put some thought into this in the past, but I couldn’t find much looking for discussions or RFCs about secrets integration, so I wanted to make a thread dedicated to it, in hopes that maybe the status quo can be moved forward.

I think there are three distinct problems:

  1. Secret retrieval/decryption/etc. NixOS can solve this: it won’t stop anyone from manually managing secrets files or using third-party secrets management tools. That said, it is not strictly necessary, and I don’t really want to push a proposal for this right now.

  2. Templating secrets into configuration files. I think it would be good if NixOS itself solved this problem, as it’s hard to solve generally otherwise.

  3. Plumbing secrets and templated configuration files into daemons. I think that NixOS modules should be solving this problem in a uniform way.

Templating

Often times today, to plumb secrets into configuration files, secrets are injected directly into files using plaintext templating or substitution. This is very powerful and is a nice fallback to have, but not necessarily the right tool for most jobs. With many configuration files using Nixpkgs’s JSON- or INI-based types, having to fall back to text templating is unideal. Escaping of special characters in secrets becomes difficult.

Because of this, I think that it would be ideal if there was some kind of standard library for doing formats with runtime replacements that can be performed e.g. on system activation, or in systemd preStart, such that you could have, for example, some kind of deferred JSON with runtime substitutions. Such that you could do something like:

{ config, lib, pkgs, ... }:
let
  json = pkgs.formats.jsonWithSubstitutions { };
  mkConfigFile = json.generate config.myConfig;
in
{
  options = {
    myConfig = lib.mkOption {
      type = json.type;
    };
  };
  config = {
    myConfig = {
      username = "hello";
      password = lib.secrets.mkSubstitution "$CREDENTIALS_DIRECTORY/password";
    };
    systemd.services.myService = {
      serviceConfig = {
        ExecStartPre = "${mkConfigFile} $RUNTIME_DIRECTORY/config.json";
        ExecStart = "${pkgs.myService}/bin/myService --config=$RUNTIME_DIRECTORY/config.json"
      };
    };
  };
}

(If needed, something like this could also be implemented using an activation script, but I think this is better.)

As for how this would work, I’m envisioning something pretty simple. The mkSubstitution calls would need to return something that could be plucked out later, perhaps a set with special attributes (maybe there’s something more elegant that could be done?), such that you could separate it into a “static” configuration and a list of substitutions, a la:

{
  value = {
    "username" = "hello";
  };
  substitutions = [
    {
      valuePath = ".password";
      replacement = "$CREDENTIALS_DIRECTORY/password";
    }
  ];
}

And then this set could be used to generate some sort of script that can perform these substitutions and output to a file. I’m thinking something that is roughly equivalent to this:

#!/nix/store/.../bin/bash
>$1 jq ".password" = $(< "$CREDENTIALS_DIRECTORY/password") /nix/store/...-value.json

…but ideally, implemented in a cleaner fashion that can properly escape, in this case, JSON values. But this is the general idea.

Most of the mechanics for this idea could be shared across multiple different formats; only the actual performance of the substitutions needs to be respun for each format.

Plumbing

Secrets stored individually in their own files is a good primitive to build off of. It’s easy for users to manage by hand, and a lot of systems provide an interface like this (e.g. nix-sops, Kubernetes, etc.) If that’s acceptable, then the problem of plumbing just becomes a problem of getting these individual secrets files into a place where the services can access them.

systemd offers LoadCredential which is an ideal way to plumb these files from the system into the services. Since it happens as root, at least by default, it can work with services that use DynamicUser without any awkward hacks. A service that supports switching DynamicUser on and off or providing configuration knobs for privileges wouldn’t need to worry about the secrets being inaccessible.

I’m envisioning that secrets could be bundled up automatically, such that it could be expressed like this:

{
  myService.secrets = {
    password = ${config.sops.secrets."myservice/Password".path}";
  };
}
{ config, lib, pkgs, ... }:
let
  json = pkgs.formats.jsonWithSubstitutions { };
  mkConfigFile = json.generate config.myConfig;
in
{
  options = {
    myConfig = lib.mkOption {
      type = json.type;
    };
    myService.secrets = lib.mkOption {
      type = lib.types.systemdCredentials; # not sure what to call it/where to put it
    };
  };
  config = {
    myConfig = {
      username = "hello";
      password = config.myService.secrets.password.substitution;
    };
    systemd.services.myService = {
      serviceConfig = {
        LoadCredential = config.myService.secrets.loadCredential;
        ExecStartPre = "${mkConfigFile} $RUNTIME_DIRECTORY/config.json";
        ExecStart = "${pkgs.myService}/bin/myService --config=$RUNTIME_DIRECTORY/config.json"
      };
    };
  };
}

One property that I am trying to maintain here is making it relatively easy for the user to define additional secrets and substitutions. This is nice because in many cases disagreements over what constitutes a “secret” necessitates a lot of unnecessary complexity. It’ll never be ideal, but making it relatively easy and self-contained for users to add more secrets seems like a good idea in general.

For complex configurations, something like Envoy where the shape of the configuration is arbitrary, this sort of capability may be strictly necessary for it to be useful.

Thoughts?

This isn’t really a well-thought-out proposal, more like some thoughts that have been floating around in my head. I’m not really convinced it is necessarily a great idea, but I’m hopeful that with refinement, we can improve how NixOS modules deal with secrets.

Something I didn’t mention is unifying all of the other secrets options. Today in NixOS, some modules provide some secrets management capabilities, but they are not all uniform. Either they forward what the upstream program offers, or they add something on top, e.g. along the lines of envsubst. It might be worthwhile to enumerate the different ways this pans out for complex software and seeing if there’s anything that can be done to unify these interfaces to all work similarly.

7 Likes

This would be awesome! Having to mangle with permissions every time I set up a new module (both for secrets and for impermanence) is a bit annoying, so having a standardized solution would make the experience a lot smoother.

You should probably work on this PR:

4 Likes

Wow, that’s pretty good timing. That PR indeed seems to do most of what I was wanting, though it’s a bit more generalized at the cost of not handling the special characters issue (as far as I can tell.)

Ah you’re certainly right about the special characters issue. I don’t have tests for that. Yet! Do you have examples that the current implementation doesn’t handle?

Btw I purposely avoided systemd LoadCredentials because of the recent RFC about allowing other init systems. Happy to revisit this choice though I still think having an independent system, less coupling, is better in the long run.

I agree though that LoadCredential solves the permission issue. I’ll need to think about that.

I definitely don’t think we should couple this to systemd anymore than necessary, but at least for NixOS modules, where we’re already coupled to systemd, my thought is that we may as well provide convenient machinery to integrate the two. Exactly how to do that while keeping the actual placeholder mechanism agnostic and decoupled is another question, of course; but LoadCredential is fairly generic, so I think it amounts to having a mechanism to generate the actual systemd LoadCredential call + a way to utilize that mechanism to also put the placeholders/substitutions that correspond to those keys into the configuration.

What I proposed here is a NixOS option type that you can put secrets in and the resulting config would contain the LoadCredential value and the values to pass into the config. I think that could work in this sort of setup, and I think one could make adaptations of this for other service managers. (Not sure if it’s ideal, though: I reckon we want NixOS modules to be less systemd-specific over time, not more. But maybe that’s not a problem; it could be a more generic “credentials” option that has <option>.systemd.loadCredentials and <option>.systemd.<secret>.systemd.credentialPath or something like that.)

Edit: Thinking more, maybe it’s worth asking what other service managers (launchd, OpenRC, any other kind of service management?) want. I know with systemd you can have the preStart run as root, if that is an option with other init daemons perhaps we can figure out a more general solution similar to LoadCredential.

2 Likes

Please don’t waste energy thinking about other service managers right now. When (if?) we ever get another service manager in NixOS, it’s up to the people maintaining that service manager to provide an alternative implementation for credentials.

I do not see the NixOS community accepting an RFC that would in any way sacrifice our support for useful systemd features in order to support three bash scripts in a trenchcoat alternative service managers. In the worst case, secrets would simply only work when using systemd and that would be fine.

5 Likes

To be clear, I mostly think it would be a shame to introduce sub-optimal design that unnecessarily couples credential injection into systemd features; at least in theory it seems like we don’t really need to do that. It seems like it would be a bummer for Nix on other operating systems where systemd doesn’t run, and I think home-manager is already dealing with some of this already.

I’m not going to fight very hard for this, though; it just seems wise to keep it in mind while trying to come up with the design. Certainly, I’m not trying to cause a huge init system debate—I’m pretty happy with systemd on my NixOS machine, but I’m also supportive of efforts like nix-darwin and putting Nix on BSDs. In general, it’s not really an issue I am passionate about.

Hopefully we’re all somewhat on the same page.

2 Likes

Alright I think I got something that looks pretty good now in the PR.

I did separate systemd integration from the core functions, so the core functions stays systemd independent and can be used on their own. This can be seen in the jsonGen test as it’s using utils.genConfigOutOfBand to generate the config file from an activation script, no systemd in sight. Of course, using this you must make sure the user running the script to generate the config file has access to secrets in the first place.

For systemd, LoadCredentials= makes the secrets accessible to the service, whatever the User= is and this works even with DynamicUser=true. I added a helper for systemd, the utils.genConfigOutOfBandSystemd function, that prepares what needs to be given to LoadCredentials= so you don’t have to. This is used in the tests otherUserGroupTmpFiles, otherUserGroupStateDirectoryManual and dynamicUserManual.

For LoadCredentials= to work, the secrets need to exist already before the service is started. So in the tests, I created them thanks to activation scripts. I used that instead of pkgs.writeText because those scripts also make sure that the permissions are set to root:root and 0700. This ensures the secrets are not accessible by all users and validates that the implementation takes this into account, which it does.

Also, to be able to write the final configuration file, the parent directory must be writable by the User= set in systemd. A first technique is to use tmpfiles.d rule which the otherUserGroupTmpFiles test is doing. This is IMO pretty clunky because we must set up this rule separately and know in advance the User= set in the systemd service. In other words, this doesn’t work with DynamicUser=true. That being said, there’s nothing wrong about this solution so I’m leaving the test.

Another way to make the directory writable is simply to use StateDirectory=. This is IMO the best way to make this works as it works with DynamicUser=true. The two tests otherUserGroupStateDirectoryManual and dynamicUserManual respectively test this without and with DynamicUser=true. The configuration file is then simply accessible under $STATE_DIRECTORY/<name>.

Now, what IMO is the best method to integrate this new functionality into systemd is using the new module option I added. It takes a list of configuration files to be generated and wires everything needed inside systemd to make them available under $STATE_DIRECTORY/<name>. This is done in the *Module tests otherUserGroupStateDirectoryModule and dynamicUserModule.

Also, I updated a few functions to work with config files containing spaces.

2 Likes