An idea for encrypted store paths

As part of Tweag I recently had the opportunity to think a bit about how encrypted store paths could work in Nix, this RFC draft came out of that.
Some problems with this idea were brought up though, so we might not continue developing it, but we think it would be beneficial to share the idea in any case.

Summary

Add support for Nix to transparently encrypt and decrypt store paths using public-private key pairs.

Motivation

Companies with private code would like to use Nix for deployments.
This is currently not a practical option because the Nix store would expose the secrets to any user on the machine.
It could also easily be copied to/from substituters and remote builders.

Detailed design

Support encrypted store paths through all layers of Nix:

  • Nix language:
    • Allow specifying store paths to be encrypted with specific public keys using builtins.path and builtins.derivation
    • Allow those primitives to return strings with context indicating the encrypted paths
  • Nix daemon:
    • Add a nix.conf option to specify local public-private key pairs and users that should have access to read paths encrypted with them
    • Create, populate and maintain a new /nix/store-decrypted directory to contain the decrypted store paths, only accessible to users linked to the corresponding key pairs
  • Nix store database:
    • Track both the encrypted /nix/store and the decrypted /nix/store-decrypted store paths together
  • Nix derivation builder:
    • Ensure that all encrypted store path inputs are readable by the build user
    • If the result should be encrypted, ensure the build happens into /nix/store-decrypted and encrypt it to /nix/store

We will also need to define supported encryption schemes.

Nix language

The builtins.path and builtins.derivation primitives gain support for new attributes to support declaring the resulting path as encrypted:

builtins.path {
  path = ./.;

  # Opt-into encryption
  encryption.enable = true;

  # The keys to encrypt for, supports multiple
  encryption.keys.<name> = ...;

  # The public-key encryption scheme identifier
  encryption.keys.<name>.scheme = "...";

  # The public-key to encrypt this path for
  encryption.keys.<name>.public-key = "...";
}

builtins.derivation {
  # ...
  
  # Opt-into encryption
  __encryption.enable = true;

  # The keys to encrypt for, supports multiple
  __encryption.keys.<name> = ...;

  # The public-key encryption scheme identifier
  __encryption.keys.<name>.scheme = "...";

  # The public-key to encrypt this path for
  __encryption.keys.<name>.public-key = "...";
}

The result of these calls is a string like "/nix/store-decrypted/<hash>-secret" with a string context like this:

{
  # For builtins.path
  "/nix/store-decrypted/<hash>-secret" = {
    path = true;
    encryption = true;
  };

  # For builtins.derivation
  "/nix/store-decrypted/<hash>-secret" = {
    outputs = [ "out" ];
    encryption = true;
  };
}

For builtins.path, the encryption parameters are directly used and encoded into the resulting encrypted store path.
For builtins.derivation, the encryption parameters are encoded into the derivation and applied after it built.

The encrypted version is stored in /nix/store/<hash>-secret, while the decrypted version is stored with appropriate access control in /nix/store-decrypted/<hash>-secret.

The derivation name is a constant so that no information about the source can be learned from it.

Note
The <hash> must be derived from the hashed inputs and a random nonce used for encryption.

Nix daemon

A new nix.conf option to declare the public-private key pairs for decrypting store paths is introduced:

  • key-pairs.<name>.scheme: The public-key encryption scheme identifier
  • key-pairs.<name>.private-key: The private key part, must be a file path and accessible to the daemon
  • key-pairs.<name>.public-key: The public key part, may be a file path, optional if the scheme can derive the public key from the private key
  • key-pairs.<name>.users: The users on this system that should have access to store paths encrypted with this public key.
    Use @nixbld to allow Nix to use the decrypted path for builds.

Whenever an encrypted path is added to the store, the deamon ensures that /nix/store and /nix/store-decrypted contain the encrypted and decrypted path respectively.
The decrypted path should have directory permissions only allowing users linked to a corresponding store paths public key.
The daemon should expect /nix/store-decrypted to be a temporary filesystem (and may even create it as such when it doesn’t exist yet).
Therefore whenever the daemon starts, it should ensure that /nix/store-decrypted is valid and repair it if necessary.

Nix store database

The Nix store database will need some changes to accomodate encrypted store paths. In particular it will need to track the encrypted /nix/store version and the decrypted /nix/store-decrypted version together.

Nix derivation builder

Needs to ensure that all derivation inputs that are encrypted store paths are readable by the build users.
While this is not strictly necessary since the build users couldn’t read the paths anyways otherwise, it can give an earlier and better error message.

Needs to ensure that the Nix client that issued the build is allowed to access the encrypted derivation inputs.
This can be done with challenge-response authentication:

  • Nix daemon sends a nonce and the public key to the Nix client
  • Nix client signs the nonce with the corresponding private key and sends the result to the Nix daemon
  • Nix daemon checks that the signature is valid for the public key

This is done for all public keys a store path was encrypted with to figure out if the client has any matching private key.

In order to make this secure, derivations with encrypted inputs are required to be built with the sandbox, such that only encrypted paths declared as inputs are accessible in the build.

Additionally for derivations needing to be encrypted, it needs to do the encryption after the build.
This also means ensuring that the output directory is in /nix/store-decrypted and not accessible to anybody other than the build user.

Probably not needed, unix sockets are already authenticated

Encryption non-reproducibility

Cryptographic security is in conflict with a demand for Nix being reproducible.
To be secure, Nix needs to incorporate randomness into the encryption algorithm, which then means that you get different results every time.
If you don’t do that, an attacker can observe the same ciphertext messages over time and correlate events to them.
This has been discussed at lengths in RFC 5.

The proposed solution here is to indeed be non-deterministic, meaning you end up with different store paths for the same input every time.
However, encrypted derivations are cached in another way, not based on the encrypted store path,
but rather by hashing all decrypted derivation inputs together as the key (can also be seen as the store path if the inputs weren’t encrypted).
When a derivation with encrypted inputs is built, the encrypted inputs are decrypted, then hashed together.
That hash is then first looked up in the local Nix database, then in substituters.

The result of the cache lookup is the plaintext contents of a previous build with the same decrypted inputs.
This allows the builder to directly copy the previous result without having to build anything
(this only works if the build initiator was able to also build the previous result, matching public key or so).

Idea: Encrypt the cache using the public key for the respective derivations. This way way substituters can stupidly store this cache.

Practically this means that derivations with encrypted inputs are built every time, but the build will be completed very fast, because the contents of a previous build will just be copied into a new directory.

This is secure against an attacker with an unprivileged user account, because they can only observe the listing of encrypted store paths.
Encrypted store paths are not reused so there’s no way to make any correlate events to specific store paths.
The attacker has also no way to observe which derivations are copied to others.

The tradeoff here is increased disk usage, since every build will create an additional copy of the encrypted store paths used. This can be counteracted with heavy use of garbage collection.

Encryption schemes

We need to define public-key encryption schemes containing procedures for encrypting and decrypting store paths.
A scheme consists of the following procedures:

  • encrypt :: Directory -> [ PublicKey ] -> File: Encrypts a directory with the given public keys, allowing any of the matching private keys to decrypt it.
  • encryptionScheme :: File -> String: Returns the encryption scheme the file was encrypted with
  • encryptionPublicKeys :: File -> [ PublicKey ]: Returns the public keys whose matching private keys could decrypt the file
  • decrypt :: File -> PrivateKey -> Directory: Decrypts the file using a private key

This RFC doesn’t specify which schemes must be implemented, but it only makes a recommendation to use libsodium’s sealed boxes.

Examples and Interactions

  • Remote builds with encrypted inputs is possible only if the remote builders have a key pair that can decrypt them.
  • Substituters work without problems since only /nix/store is substituted, while /nix/store-decrypted is always local.
8 Likes

That seems like a great idea, but wouldn’t it also be useful for a much wider usecase, namely to add encrypted secrets (database passwords…) in the store? For now existing solutions are often dirty and rely on the user’s ability to properly distribute secret files. In this scenario, the password could even be hardcoded in the nix file itself (assuming of course that the nix’s repository is kept secret).

Why not just keep the secret stuff outside the nix store? Then you can have all the ACLs and xattrs and encrypted filesystems you want. You can even keep the store paths (inside the encrypted container). All you need to do is place some symlinks and/or wrappers into the store:

#/usr/bin/env bash

#prepare, assume /tmp/ is actually a container mounted with cryptsetup and ACLs set :)
NIXPKGS_FLAKE=nixpkgs
MYHELLO=$(mktemp) ; cp $(nix build --print-out-paths $NIXPKGS_FLAKE#pkgsStatic.hello)/bin/hello $MYHELLO; chmod +x $MYHELLO; $MYHELLO
MY_NIX_PATH=$(nix flake metadata $NIXPKGS_FLAKE --json | nix run $NIXPKGS_FLAKE#jq .path | tr -d '"' )
MY_TMP=$(mktemp -d) ; ln -s $MY_NIX_PATH $MY_TMP/nixpkgs

nix build --impure -I $MY_TMP --arg mypath "$MYHELLO" --expr \
'{ mypath }:
let pkgs = import <nixpkgs> {};
in
pkgs.stdenv.mkDerivation {
  name = "link-outside-store";
  src = null;
  dontUnpack = true;
  dontPatch = true;
  dontBuild = true;
  installPhase = "mkdir -p $out/bin; ln -ns ${toString mypath} $out/bin/foo";
}'

ls -alth result/bin/foo
result/bin/foo

Edit: Of course the encrypted container can be distributed as a store path.
Edit2: This can probably be implemented with some mkEncryptedDerivation wrapper around mkDerivation.

Why does this idea need encryption at all? It’s not really a /nix/store-decrypted so much as it is a /nix/store-access-controlled, where the files in there are added to a build’s sandbox only if the client can provide some arbitrary method of authentication.

Also this doesn’t exactly resolve the issue of wanting to deploy a secret for use at runtime, right? It’s only a mechanism for providing secrets during build time.