diff --git a/.envrc b/.envrc index 3550a30f..0f94eede 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ +# shellcheck shell=bash use flake diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b3e026df..6db6026e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,14 +5,14 @@ on: branches: - master schedule: - - cron: '51 2 * * *' + - cron: "51 2 * * *" jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v30 - - name: Add keys group (needed for go tests) - run: sudo groupadd keys - - name: Run unit tests - run: nix develop .#unit-tests --command "true" + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v30 + - name: Add keys group (needed for go tests) + run: sudo groupadd keys + - name: Run unit tests + run: nix develop .#unit-tests --command "true" diff --git a/.github/workflows/upgrade-flakes.yml b/.github/workflows/upgrade-flakes.yml index ed3d8918..a49f48a3 100644 --- a/.github/workflows/upgrade-flakes.yml +++ b/.github/workflows/upgrade-flakes.yml @@ -3,7 +3,7 @@ on: repository_dispatch: workflow_dispatch: schedule: - - cron: '51 2 * * 0' + - cron: "51 2 * * 0" permissions: pull-requests: write @@ -20,10 +20,10 @@ jobs: access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} - name: Update flakes run: | - nix flake update - pushd dev/private - nix flake update - popd - nix run .#update-dev-private-narHash + nix flake update + pushd dev/private + nix flake update + popd + nix run .#update-dev-private-narHash - name: Create Pull Request uses: peter-evans/create-pull-request@v7 diff --git a/.golangci.yml b/.golangci.yml index 2b94076c..433b33de 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,12 +1,20 @@ linters: - presets: - - bugs - - unused - enable: - - gofmt - - misspell - - revive - - stylecheck + enable-all: true disable: - # direnv is not a web server, context is not strictly necessary. - - noctx + - cyclop + - depguard + - dogsled + - err113 + - exhaustruct + - exportloopref + - forbidigo + - funlen + - gocognit + - gocyclo + - godot + - godox + - lll + - maintidx + - mnd + - nestif + - varnamelen diff --git a/README.md b/README.md index 5d5769f9..1ada466b 100644 --- a/README.md +++ b/README.md @@ -2,63 +2,87 @@ ![sops-nix logo](https://github.com/Mic92/sops-nix/releases/download/assets/logo.gif "Logo of sops-nix") -Atomic, declarative, and reproducible secret provisioning for NixOS based on [sops](https://github.com/mozilla/sops). +Atomic, declarative, and reproducible secret provisioning for NixOS based on +[sops](https://github.com/mozilla/sops). ## How it works -Secrets are decrypted from [`sops` files](https://github.com/mozilla/sops#2usage) during -activation time. The secrets are stored as one secret per file and access-controlled by full declarative configuration of their users, permissions, and groups. -GPG keys or `age` keys can be used for decryption, and compatibility shims are supported to enable the use of SSH RSA or SSH Ed25519 keys. -Sops also supports cloud key management APIs such as AWS -KMS, GCP KMS, Azure Key Vault and Hashicorp Vault. While not -officially supported by sops-nix yet, these can be controlled using +Secrets are decrypted from +[`sops` files](https://github.com/mozilla/sops#2usage) during activation time. +The secrets are stored as one secret per file and access-controlled by full +declarative configuration of their users, permissions, and groups. GPG keys or +`age` keys can be used for decryption, and compatibility shims are supported to +enable the use of SSH RSA or SSH Ed25519 keys. Sops also supports cloud key +management APIs such as AWS KMS, GCP KMS, Azure Key Vault and Hashicorp Vault. +While not officially supported by sops-nix yet, these can be controlled using environment variables that can be passed to sops. ## Features -- Compatible with all NixOS deployment frameworks: [NixOps](https://github.com/NixOS/nixops), nixos-rebuild, [krops](https://github.com/krebs/krops/), [morph](https://github.com/DBCDK/morph), [nixus](https://github.com/Infinisil/nixus), etc. -- Version-control friendly: Since all files are encrypted they can be directly committed to version control without worry. Diffs of the secrets are readable, and [can be shown in cleartext](https://github.com/mozilla/sops#showing-diffs-in-cleartext-in-git). -- CI friendly: Since sops files can be added to the Nix store without leaking secrets, a machine definition can be built as a whole from a repository, without needing to rely on external secrets or services. +- Compatible with all NixOS deployment frameworks: + [NixOps](https://github.com/NixOS/nixops), nixos-rebuild, + [krops](https://github.com/krebs/krops/), + [morph](https://github.com/DBCDK/morph), + [nixus](https://github.com/Infinisil/nixus), etc. +- Version-control friendly: Since all files are encrypted they can be directly + committed to version control without worry. Diffs of the secrets are readable, + and + [can be shown in cleartext](https://github.com/mozilla/sops#showing-diffs-in-cleartext-in-git). +- CI friendly: Since sops files can be added to the Nix store without leaking + secrets, a machine definition can be built as a whole from a repository, + without needing to rely on external secrets or services. - Home-manager friendly: Provides a home-manager module -- Works well in teams: sops-nix comes with `nix-shell` hooks that allows multiple people to quickly import all GPG keys. - The cryptography used in sops is designed to be scalable: Secrets are only encrypted once with a master key +- Works well in teams: sops-nix comes with `nix-shell` hooks that allows + multiple people to quickly import all GPG keys. The cryptography used in sops + is designed to be scalable: Secrets are only encrypted once with a master key instead of encrypted per machine/developer key. -- Atomic upgrades: New secrets are written to a new directory which replaces the old directory atomically. -- Rollback support: If sops files are added to the Nix store, old secrets can be rolled back. This is optional. -- Fast time-to-deploy: Unlike solutions implemented by NixOps, krops and morph, no extra steps are required to upload secrets. -- A variety of storage formats: Secrets can be stored in YAML, dotenv, INI, JSON or binary. -- Minimizes configuration errors: sops files are checked against the configuration at evaluation time. +- Atomic upgrades: New secrets are written to a new directory which replaces the + old directory atomically. +- Rollback support: If sops files are added to the Nix store, old secrets can be + rolled back. This is optional. +- Fast time-to-deploy: Unlike solutions implemented by NixOps, krops and morph, + no extra steps are required to upload secrets. +- A variety of storage formats: Secrets can be stored in YAML, dotenv, INI, JSON + or binary. +- Minimizes configuration errors: sops files are checked against the + configuration at evaluation time. ## Demo -There is a `configuration.nix` example in the [deployment step](#deploy-example) of our usage example. +There is a `configuration.nix` example in the [deployment step](#deploy-example) +of our usage example. ## Supported encryption methods sops-nix supports two basic ways of encryption, GPG and `age`. -GPG is based on [GnuPG](https://gnupg.org/) and encrypts against GPG public keys. Private GPG keys may -be used to decrypt the secrets on the target machine. The tool [`ssh-to-pgp`](https://github.com/Mic92/ssh-to-pgp) can -be used to derive a GPG key from a SSH (host) key in RSA format. +GPG is based on [GnuPG](https://gnupg.org/) and encrypts against GPG public +keys. Private GPG keys may be used to decrypt the secrets on the target machine. +The tool [`ssh-to-pgp`](https://github.com/Mic92/ssh-to-pgp) can be used to +derive a GPG key from a SSH (host) key in RSA format. -The other method is `age` which is based on [`age`](https://github.com/FiloSottile/age). -The tool ([`ssh-to-age`](https://github.com/Mic92/ssh-to-age)) can convert SSH host or user keys in Ed25519 -format to `age` keys. +The other method is `age` which is based on +[`age`](https://github.com/FiloSottile/age). The tool +([`ssh-to-age`](https://github.com/Mic92/ssh-to-age)) can convert SSH host or +user keys in Ed25519 format to `age` keys. ## Usage example -If you prefer video over the textual description below, you can also checkout this [6min tutorial](https://www.youtube.com/watch?v=G5f6GC7SnhU) by [@vimjoyer](https://github.com/vimjoyer). +If you prefer video over the textual description below, you can also checkout +this [6min tutorial](https://www.youtube.com/watch?v=G5f6GC7SnhU) by +[@vimjoyer](https://github.com/vimjoyer).
1. Install sops-nix -Choose one of the following methods. When using it non-globally with home-manager, refer to [Use with home-manager](#use-with-home-manager). +Choose one of the following methods. When using it non-globally with +home-manager, refer to [Use with home-manager](#use-with-home-manager). #### Flakes (current recommendation) If you use experimental nix flakes support: -``` nix +```nix { inputs.sops-nix.url = "github:Mic92/sops-nix"; # optional, not necessary for the module @@ -79,23 +103,24 @@ If you use experimental nix flakes support: ``` #### [`niv`](https://github.com/nmattia/niv) (recommended if not using flakes) - First add it to niv: - + +First add it to niv: + ```console $ niv add Mic92/sops-nix ``` - Then add the following to your `configuration.nix` in the `imports` list: - +Then add the following to your `configuration.nix` in the `imports` list: + ```nix { imports = [ "${(import ./nix/sources.nix).sops-nix}/modules/sops" ]; } ``` - + #### `fetchTarball` - Add the following to your `configuration.nix`: +Add the following to your `configuration.nix`: ```nix { @@ -111,7 +136,7 @@ $ niv add Mic92/sops-nix ]; } ``` - +
@@ -134,7 +159,8 @@ $ gpg --full-generate-key $ gpg --default-new-key-algo rsa4096 --gen-key ``` -Or you can use the `ssh-to-pgp` tool to get a GPG key from an SSH key: +Or you can use the `ssh-to-pgp` tool to get a GPG key from an SSH key: + ```console $ nix-shell -p gnupg -p ssh-to-pgp --run "ssh-to-pgp -private-key -i $HOME/.ssh/id_rsa | gpg --import --quiet" 2504791468b153b8a3963cc97ba53d1919c5dfd4 @@ -142,12 +168,18 @@ $ nix-shell -p gnupg -p ssh-to-pgp --run "ssh-to-pgp -private-key -i $HOME/.ssh/ $ nix-shell -p ssh-to-pgp --run "ssh-to-pgp -i $HOME/.ssh/id_rsa -o $USER.asc" 2504791468b153b8a3963cc97ba53d1919c5dfd4 ``` -(Note that `ssh-to-pgp` only supports RSA keys; to use Ed25519 keys, use `age`.) + +(Note that `ssh-to-pgp` only supports RSA keys; to use Ed25519 keys, use +`age`.)\ If you get the following, + ```console ssh-to-pgp: failed to parse private ssh key: ssh: this private key is passphrase protected ``` -then your SSH key is encrypted with your password and you will need to create an unencrypted copy temporarily. + +then your SSH key is encrypted with your password and you will need to create an +unencrypted copy temporarily. + ```console $ cp $HOME/.ssh/id_rsa /tmp/id_rsa $ ssh-keygen -p -N "" -f /tmp/id_rsa @@ -158,13 +190,16 @@ $ rm /tmp/id_rsa
How to find the public key of an `age` key -If you generated an `age` key, the `age` public key can be found via `age-keygen -y $PATH_TO_KEY`: +If you generated an `age` key, the `age` public key can be found via +`age-keygen -y $PATH_TO_KEY`: + ```console $ age-keygen -y ~/.config/sops/age/keys.txt age12zlz6lvcdk6eqaewfylg35w0syh58sm7gh53q5vvn7hd7c6nngyseftjxl ``` Otherwise, you can convert an existing SSH key into an `age` public key: + ```console $ nix-shell -p ssh-to-age --run "ssh-to-age < ~/.ssh/id_ed25519.pub" # or @@ -177,6 +212,7 @@ $ nix-shell -p ssh-to-age --run "ssh-add -L | ssh-to-age" How to find the GPG fingerprint of a key Invoke this command and look for your key: + ```console $ gpg --list-secret-keys /tmp/tmp.JA07D1aVRD/pubring.kbx @@ -187,11 +223,15 @@ uid [ unknown] root ``` The fingerprint here is `9F89C5F69A10281A835014B09C3DC61F752087EF`. +
-Your `age` public key or GPG fingerprint can be written to your [`.sops.yaml`](https://github.com/getsops/sops#using-sops-yaml-conf-to-select-kms-pgp-and-age-for-new-files) in the root of your configuration directory or repository: +Your `age` public key or GPG fingerprint can be written to your +[`.sops.yaml`](https://github.com/getsops/sops#using-sops-yaml-conf-to-select-kms-pgp-and-age-for-new-files) +in the root of your configuration directory or repository: + ```yaml -# This example uses YAML anchors which allows reuse of multiple keys +# This example uses YAML anchors which allows reuse of multiple keys # without having to repeat yourself. # Also see https://github.com/Mic92/dotfiles/blob/master/nixos/.sops.yaml # for a more complex example. @@ -201,15 +241,14 @@ keys: creation_rules: - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$ key_groups: - - pgp: - - *admin_alice - age: - - *admin_bob + - pgp: + - *admin_alice + age: + - *admin_bob ``` -**Note:** -Be sure to not include a `-` before subsequent key types under `key_groups` -(i.e. `age` in the above example should not have a `-` in front). +**Note:** Be sure to not include a `-` before subsequent key types under +`key_groups` (i.e. `age` in the above example should not have a `-` in front). This will otherwise cause sops to require multiple keys (shamir secret sharing) to decrypt a secret, which breaks normal sops-nix usage. @@ -218,9 +257,12 @@ to decrypt a secret, which breaks normal sops-nix usage.
3. Get a public key for your target machine -The easiest way to add new machines is by using SSH host keys (this requires OpenSSH to be enabled). +The easiest way to add new machines is by using SSH host keys (this requires +OpenSSH to be enabled). + +If you are using `age`, the `ssh-to-age` tool can be used to convert any SSH +Ed25519 public key to the `age` format: -If you are using `age`, the `ssh-to-age` tool can be used to convert any SSH Ed25519 public key to the `age` format: ```console $ nix-shell -p ssh-to-age --run 'ssh-keyscan example.com | ssh-to-age' age1rgffpespcyjn0d8jglk7km9kfrfhdyev6camd3rck6pn8y47ze4sug23v3 @@ -228,7 +270,8 @@ $ nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to- age1rgffpespcyjn0d8jglk7km9kfrfhdyev6camd3rck6pn8y47ze4sug23v3 ``` -For GPG, since sops does not natively support SSH keys yet, sops-nix supports a conversion tool (`ssh-to-pgp`) to store them as GPG keys: +For GPG, since sops does not natively support SSH keys yet, sops-nix supports a +conversion tool (`ssh-to-pgp`) to store them as GPG keys: ```console $ ssh root@server01 "cat /etc/ssh/ssh_host_rsa_key" | nix-shell -p ssh-to-pgp --run "ssh-to-pgp -o server01.asc" @@ -240,7 +283,8 @@ $ nix-shell -p ssh-to-pgp --run "ssh-to-pgp -i /etc/ssh/ssh_host_rsa_key -o serv 0fd60c8c3b664aceb1796ce02b318df330331003 ``` -The output of these commands is the identifier for the server's key, which can be added to your `.sops.yaml`: +The output of these commands is the identifier for the server's key, which can +be added to your `.sops.yaml`: ```yaml keys: @@ -251,22 +295,23 @@ keys: creation_rules: - path_regex: secrets/[^/]+\.(yaml|json|env|ini)$ key_groups: - - pgp: - - *admin_alice - - *server_azmidi - age: - - *admin_bob - - *server_nosaxa + - pgp: + - *admin_alice + - *server_azmidi + age: + - *admin_bob + - *server_nosaxa - path_regex: secrets/azmidi/[^/]+\.(yaml|json|env|ini)$ key_groups: - - pgp: - - *admin_alice - - *server_azmidi - age: - - *admin_bob + - pgp: + - *admin_alice + - *server_azmidi + age: + - *admin_bob ``` -If you prefer having a separate GPG key, see [Use with GPG instead of SSH keys](#use-with-GPG-instead-of-SSH-keys). +If you prefer having a separate GPG key, see +[Use with GPG instead of SSH keys](#use-with-GPG-instead-of-SSH-keys).
@@ -275,8 +320,8 @@ If you prefer having a separate GPG key, see [Use with GPG instead of SSH keys]( To create a sops file you need write a `.sops.yaml` as described above. -When using GnuPG you also need to import your personal GPG key -(and your colleagues) and your servers into your GPG key chain. +When using GnuPG you also need to import your personal GPG key (and your +colleagues) and your servers into your GPG key chain.
sops-nix can automate the import of GPG keys with a hook for nix-shell, allowing public @@ -342,8 +387,10 @@ After configuring `.sops.yaml`, you can open a new file with sops: $ nix-shell -p sops --run "sops secrets/example.yaml" ``` -This will start your configured editor located at the `$EDITOR` environment variable. +This will start your configured editor located at the `$EDITOR` environment +variable.\ An example secret file might be: + ```yaml # Files must always have a string value example-key: example-value @@ -394,7 +441,9 @@ sops: version: 3.7.1 ``` -If you add a new host to your `.sops.yaml` file, you will need to update the keys for all secrets that are used by the new host. This can be done like so: +If you add a new host to your `.sops.yaml` file, you will need to update the +keys for all secrets that are used by the new host. This can be done like so: + ``` $ nix-shell -p sops --run "sops updatekeys secrets/example.yaml" ``` @@ -404,7 +453,8 @@ $ nix-shell -p sops --run "sops updatekeys secrets/example.yaml"
5. Deploy -If you derived your server public key from SSH, all you need in your `configuration.nix` is: +If you derived your server public key from SSH, all you need in your +`configuration.nix` is: ```nix { @@ -425,8 +475,8 @@ If you derived your server public key from SSH, all you need in your `configurat } ``` -On `nixos-rebuild switch` this will make the keys accessible -via `/run/secrets/example-key` and `/run/secrets/myservice/my_subdir/my_secret`: +On `nixos-rebuild switch` this will make the keys accessible via +`/run/secrets/example-key` and `/run/secrets/myservice/my_subdir/my_secret`: ```console $ cat /run/secrets/example-key @@ -446,11 +496,11 @@ lrwxrwxrwx 16 root 12 Jul 6:23  /run/secrets -> /run/secrets.d/1 ## Set secret permission/owner and allow services to access it -By default secrets are owned by `root:root`. Furthermore -the parent directory `/run/secrets.d` is only owned by -`root` and the `keys` group has read access to it: +By default secrets are owned by `root:root`. Furthermore the parent directory +`/run/secrets.d` is only owned by `root` and the `keys` group has read access to +it: -``` console +```console $ ls -la /run/secrets.d/1 total 24 drwxr-x--- 2 root keys 0 Jul 12 6:23 . @@ -458,8 +508,8 @@ drwxr-x--- 3 root keys 0 Jul 12 6:23 .. -r-------- 1 root root 20 Jul 12 6:23 example-secret ``` -The secrets option has further parameter to change secret permission. -Consider the following nixos configuration example: +The secrets option has further parameter to change secret permission. Consider +the following nixos configuration example: ```nix { @@ -514,9 +564,11 @@ the service needs a token and a SSH private key to function.
## Restarting/reloading systemd units on secret change -It is possible to restart or reload units when a secret changes or is newly initialized. +It is possible to restart or reload units when a secret changes or is newly +initialized. This behavior can be configured per-secret: + ```nix { sops.secrets."home-assistant-secrets.yaml" = { @@ -528,9 +580,8 @@ This behavior can be configured per-secret: ## Symlinks to other directories -Some services might expect files in certain locations. -Using the `path` option a symlink to this directory can -be created: +Some services might expect files in certain locations. Using the `path` option a +symlink to this directory can be created: ```nix { @@ -548,13 +599,17 @@ lrwxrwxrwx 1 root root 40 Jul 19 22:36 /var/lib/hass/secrets.yaml -> /run/secret ## Setting a user's password -sops-nix has to run after NixOS creates users (in order to specify what users own a secret.) -This means that it's not possible to set `users.users..hashedPasswordFile` to any secrets managed by sops-nix. -To work around this issue, it's possible to set `neededForUsers = true` in a secret. -This will cause the secret to be decrypted to `/run/secrets-for-users` instead of `/run/secrets` before NixOS creates users. -As users are not created yet, it's not possible to set an owner for these secrets. +sops-nix has to run after NixOS creates users (in order to specify what users +own a secret.) This means that it's not possible to set +`users.users..hashedPasswordFile` to any secrets managed by sops-nix. To +work around this issue, it's possible to set `neededForUsers = true` in a +secret. This will cause the secret to be decrypted to `/run/secrets-for-users` +instead of `/run/secrets` before NixOS creates users. As users are not created +yet, it's not possible to set an owner for these secrets. + +The password must be stored as a hash for this to work, which can be created +with the command `mkpasswd` -The password must be stored as a hash for this to work, which can be created with the command `mkpasswd` ```console $ echo "password" | mkpasswd -s $y$j9T$WFoiErKnEnMcGq0ruQK4K.$4nJAY3LBeBsZBTYSkdTOejKU6KlDmhnfUV3Ll1K/1b. @@ -571,13 +626,15 @@ $y$j9T$WFoiErKnEnMcGq0ruQK4K.$4nJAY3LBeBsZBTYSkdTOejKU6KlDmhnfUV3Ll1K/1b. } ``` -**Note:** If you are using Impermanence, you must set `sops.age.keyFile` to a keyfile inside your persist directory or it will not exist at boot time. -For example: `/nix/persist/var/lib/sops-nix/key.txt` -Similarly if ssh host keys are used instead, they also need to be placed inside the persisted storage. +**Note:** If you are using Impermanence, you must set `sops.age.keyFile` to a +keyfile inside your persist directory or it will not exist at boot time. For +example: `/nix/persist/var/lib/sops-nix/key.txt` Similarly if ssh host keys are +used instead, they also need to be placed inside the persisted storage. ## Different file formats -At the moment we support the following file formats: YAML, JSON, INI, dotenv and binary. +At the moment we support the following file formats: YAML, JSON, INI, dotenv and +binary. sops-nix allows specifying multiple sops files in different file formats: @@ -644,7 +701,7 @@ $ sops secrets.json Then, put in the following content: -``` json +```json { "github_token": "4a6c73f74928a9c4c4bc47379256b72e598e2bd3", "ssh_key": "-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\\nQyNTUxOQAAACDENhLwQI4v/Ecv65iCMZ7aZAL+Sdc0Cqyjkd012XwJzQAAAJht4at6beGr\\negAAAAtzc2gtZWQyNTUxOQAAACDENhLwQI4v/Ecv65iCMZ7aZAL+Sdc0Cqyjkd012XwJzQ\\nAAAEBizgX7v+VMZeiCtWRjpl95dxqBWUkbrPsUSYF3DGV0rsQ2EvBAji/8Ry/rmIIxntpk\\nAv5J1zQKrKOR3TXZfAnNAAAAE2pvZXJnQHR1cmluZ21hY2hpbmUBAg==\\n-----END OPENSSH PRIVATE KEY-----\\n" @@ -669,11 +726,12 @@ You can include it like this in your `configuration.nix`: ### Binary This format allows to encrypt an arbitrary binary format that can't be put into -JSON/YAML files. Unlike the other two formats, for binary files, one file corresponds to one secret. +JSON/YAML files. Unlike the other two formats, for binary files, one file +corresponds to one secret. To encrypt an binary file use the following command: -``` console +```console $ sops -e /etc/krb5/krb5.keytab > krb5.keytab # an example of what this might result in: $ head krb5.keytab @@ -687,12 +745,11 @@ $ head krb5.keytab "mac": "ENC[AES256_GCM,data:ISjUzaw/5mNiwypmUrOk2DAZnlkbnhURHmTTYA3705NmRsSyUh1PyQvCuwglmaHscwl4GrsnIz4rglvwx1zYa+UUwanR0+VeBqntHwzSNiWhh7qMAQwdUXmdCNiOyeGy6jcSDsXUeQmyIWH6yibr7hhzoQFkZEB7Wbvcw6Sossk=,iv:UilxNvfHN6WkEvfY8ZIJCWijSSpLk7fqSCWh6n8+7lk=,tag:HUTgyL01qfVTCNWCTBfqXw==,type:str]", "pgp": [ { - ``` It can be decrypted again like this: -``` console +```console $ sops -d krb5.keytab > /tmp/krb5.keytab ``` @@ -709,9 +766,9 @@ This is how it can be included in your `configuration.nix`: ## Emit plain file for yaml and json formats -By default, sops-nix extracts a single key from yaml and json files. If you -need the plain file instead of extracting a specific key from the input document, -you can set `key` to an empty string. +By default, sops-nix extracts a single key from yaml and json files. If you need +the plain file instead of extracting a specific key from the input document, you +can set `key` to an empty string. For example, the input document `my-config.yaml` likes this: @@ -719,10 +776,10 @@ For example, the input document `my-config.yaml` likes this: my-secret1: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str] my-secret2: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str] sops: - kms: [] - gcp_kms: [] - azure_kv: [] - hc_vault: [] + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] ... ``` @@ -747,16 +804,25 @@ my-secret2: hello ## Use with home manager -sops-nix also provides a home-manager module. -This module provides a subset of features provided by the system-wide sops-nix since features like the creation of the ramfs and changing the owner of the secrets are not available for non-root users. - -Instead of running as an activation script, sops-nix runs as a systemd user service called `sops-nix.service`. -While the sops-nix _system_ module decrypts secrets to the system non-persistent `/run/secrets`, the _home-manager_ module places them in the users non-persistent `$XDG_RUNTIME_DIR/secrets.d`. -Additionally secrets are symlinked to the users home at `$HOME/.config/sops-nix/secrets` which are referenced for the `.path` value in sops-nix. -This requires that the home-manager option `home.homeDirectory` is set to determine the home-directory on evaluation. It will have to be manually set if home-manager is configured as stand-alone or on non NixOS systems. - -Depending on whether you use home-manager system-wide or stand-alone using a home.nix, you have to import it in a different way. -This example shows the `flake` approach from the recommended example [Install: Flakes (current recommendation)](#Flakes (current recommendation)) +sops-nix also provides a home-manager module. This module provides a subset of +features provided by the system-wide sops-nix since features like the creation +of the ramfs and changing the owner of the secrets are not available for +non-root users. + +Instead of running as an activation script, sops-nix runs as a systemd user +service called `sops-nix.service`. While the sops-nix _system_ module decrypts +secrets to the system non-persistent `/run/secrets`, the _home-manager_ module +places them in the users non-persistent `$XDG_RUNTIME_DIR/secrets.d`. +Additionally secrets are symlinked to the users home at +`$HOME/.config/sops-nix/secrets` which are referenced for the `.path` value in +sops-nix. This requires that the home-manager option `home.homeDirectory` is set +to determine the home-directory on evaluation. It will have to be manually set +if home-manager is configured as stand-alone or on non NixOS systems. + +Depending on whether you use home-manager system-wide or stand-alone using a +home.nix, you have to import it in a different way. This example shows the +`flake` approach from the recommended example +[Install: Flakes (current recommendation)](#Flakes "current recommendation") ```nix { @@ -776,7 +842,8 @@ This example shows the `flake` approach from the recommended example [Install: F } ``` -This example show the `channel` approach from the example [Install: nix-channel](#nix-channel). All other methods work as well. +This example show the `channel` approach from the example +[Install: nix-channel](#nix-channel). All other methods work as well. ```nix { @@ -796,7 +863,9 @@ This example show the `channel` approach from the example [Install: nix-channel] } ``` -The actual sops configuration is in the `sops` namespace in your home.nix (or in the `home-manager.users.` namespace when using home-manager system-wide): +The actual sops configuration is in the `sops` namespace in your home.nix (or in +the `home-manager.users.` namespace when using home-manager system-wide): + ```nix { sops = { @@ -816,7 +885,9 @@ The actual sops configuration is in the `sops` namespace in your home.nix (or in } ``` -The secrets are decrypted in a systemd user service called `sops-nix`, so other services needing secrets must order after it: +The secrets are decrypted in a systemd user service called `sops-nix`, so other +services needing secrets must order after it: + ```nix { systemd.user.services.mbsync.Unit.After = [ "sops-nix.service" ]; @@ -825,9 +896,11 @@ The secrets are decrypted in a systemd user service called `sops-nix`, so other ### Qubes Split GPG support -If you are using Qubes with the [Split GPG](https://www.qubes-os.org/doc/split-gpg), -then you can configure sops to utilize the `qubes-gpg-client-wrapper` with the `sops.gnupg.qubes-split-gpg` options. -The example above updated looks like this: +If you are using Qubes with the +[Split GPG](https://www.qubes-os.org/doc/split-gpg), then you can configure sops +to utilize the `qubes-gpg-client-wrapper` with the `sops.gnupg.qubes-split-gpg` +options. The example above updated looks like this: + ```nix { sops = { @@ -850,7 +923,8 @@ The example above updated looks like this: ## Use with GPG instead of SSH keys -If you prefer having a separate GPG key, sops-nix also comes with a helper tool, `sops-init-gpg-key`: +If you prefer having a separate GPG key, sops-nix also comes with a helper tool, +`sops-init-gpg-key`: ```console $ nix run github:Mic92/sops-nix#sops-init-gpg-key -- --hostname server01 --gpghome /tmp/newkey @@ -904,8 +978,9 @@ fingerprint: 4413684FC623628CEA3E0929AB2F16C6B5EF89EF F0477297E369CD1D189DD901278D1535AB473B9E ``` -In both cases, you must upload the GPG key directory `/tmp/newkey` onto the server. -If you uploaded it to `/var/lib/sops` than your sops configuration will look like this: +In both cases, you must upload the GPG key directory `/tmp/newkey` onto the +server. If you uploaded it to `/var/lib/sops` than your sops configuration will +look like this: ```nix { @@ -917,10 +992,11 @@ If you uploaded it to `/var/lib/sops` than your sops configuration will look lik ``` However be aware that this will also run GnuPG on your server including the -GnuPG daemon. [GnuPG is in general not great software](https://latacora.micro.blog/2019/07/16/the-pgp-problem.html) and might break in -hilarious ways. If you experience problems, you are on your own. If you want a -more stable and predictable solution go with SSH keys or one of the KMS services. - +GnuPG daemon. +[GnuPG is in general not great software](https://latacora.micro.blog/2019/07/16/the-pgp-problem.html) +and might break in hilarious ways. If you experience problems, you are on your +own. If you want a more stable and predictable solution go with SSH keys or one +of the KMS services. ## Share secrets between different users @@ -945,8 +1021,8 @@ example the `drone` secret is exposed as `/run/secrets/drone-server` for ## Migrate from pass/krops If you have used [pass](https://www.passwordstore.org) before (e.g. in -[krops](https://github.com/krebs/krops)) than you can use the following one-liner -to convert all your secrets to a YAML structure: +[krops](https://github.com/krebs/krops)) than you can use the following +one-liner to convert all your secrets to a YAML structure: ```console $ for i in *.gpg; do echo "$(basename $i .gpg): |\n$(pass $(dirname $i)/$(basename $i .gpg)| sed 's/^/ /')"; done @@ -956,21 +1032,23 @@ Copy the output to the editor you have opened with sops. ## Real-world examples -The [nix-community infra](https://github.com/nix-community/infra) makes extensive usage of sops-nix. -Each host has a [secrets.yaml](https://github.com/nix-community/infra/tree/master/hosts/build01) containing secrets for the host. -Also Samuel Leathers explains his personal setup in this [blog article](https://samleathers.com/posts/2022-02-11-my-new-network-and-sops.html). +The [nix-community infra](https://github.com/nix-community/infra) makes +extensive usage of sops-nix. Each host has a +[secrets.yaml](https://github.com/nix-community/infra/tree/master/hosts/build01) +containing secrets for the host. Also Samuel Leathers explains his personal +setup in this +[blog article](https://samleathers.com/posts/2022-02-11-my-new-network-and-sops.html). ## Known limitations ### Initrd secrets -sops-nix does not fully support initrd secrets. -This is because `nixos-rebuild switch` installs -the bootloader before running sops-nix's activation hook. -As a workaround, it is possible to run `nixos-rebuild test` -before `nixos-rebuild switch` to provision initrd secrets -before actually using them in the initrd. -In the future, we hope to extend NixOS to allow keys to be +sops-nix does not fully support initrd secrets. This is because +`nixos-rebuild switch` installs the bootloader before running sops-nix's +activation hook.\ +As a workaround, it is possible to run `nixos-rebuild test` before +`nixos-rebuild switch` to provision initrd secrets before actually using them in +the initrd. In the future, we hope to extend NixOS to allow keys to be provisioned in the bootloader install phase. ### Using secrets at evaluation time @@ -985,13 +1063,15 @@ can be used together with sops-nix. ## Templates -If your setup requires embedding secrets within a configuration file, the `template` feature of `sops-nix` provides a seamless way to do this. +If your setup requires embedding secrets within a configuration file, the +`template` feature of `sops-nix` provides a seamless way to do this. Here's how to use it: 1. **Define Your Secret** - Specify the secrets you intend to use. This will be encrypted and managed securely by `sops-nix`. + Specify the secrets you intend to use. This will be encrypted and managed + securely by `sops-nix`. ```nix { @@ -1001,8 +1081,9 @@ Here's how to use it: 2. **Use Templates for Configuration with Secrets** - Create a template for your configuration file and utilize the placeholder where you'd like the secret to be inserted. - During the activation phase, `sops-nix` will substitute the placeholder with the actual secret content. + Create a template for your configuration file and utilize the placeholder + where you'd like the secret to be inserted. During the activation phase, + `sops-nix` will substitute the placeholder with the actual secret content. ```nix { @@ -1022,7 +1103,8 @@ Here's how to use it: 3. **Reference the Rendered Configuration in Services** - When defining a service (e.g., using `systemd`), refer to the rendered configuration (with secrets in place) by leveraging the `.path` attribute. + When defining a service (e.g., using `systemd`), refer to the rendered + configuration (with secrets in place) by leveraging the `.path` attribute. ```nix { @@ -1045,12 +1127,14 @@ Here's how to use it: mechanism to inject secrets into configuration files in the nixos activation phase - # Need more commercial support? +We are building sops-nix very much as contributors to the community and are +committed to keeping it open source. -We are building sops-nix very much as contributors to the community and are committed to keeping it open source. +That said, many of us that are contributing to sops-nix also work for +consultancies. If you want to contact one of those for paid-for support setting +up sops-nix in your infrastructure you can do so here: -That said, many of us that are contributing to sops-nix also work for consultancies. If you want to contact one of those for paid-for support setting up sops-nix in your infrastructure you can do so here: -* [Numtide](https://numtide.com/contact) -* [Helsinki Systems](https://helsinki-systems.de/) +- [Numtide](https://numtide.com/contact) +- [Helsinki Systems](https://helsinki-systems.de/) diff --git a/checks/darwin.nix b/checks/darwin.nix index 8b74e334..8f7308bb 100644 --- a/checks/darwin.nix +++ b/checks/darwin.nix @@ -1,4 +1,3 @@ - { imports = [ ../modules/nix-darwin/default.nix diff --git a/checks/home-manager.nix b/checks/home-manager.nix index d1e63552..06280cda 100644 --- a/checks/home-manager.nix +++ b/checks/home-manager.nix @@ -1,5 +1,5 @@ - -{ config, ... }: { +{ config, ... }: +{ imports = [ ../modules/home-manager/sops.nix ]; diff --git a/default.nix b/default.nix index 63abb9b1..7e15b832 100644 --- a/default.nix +++ b/default.nix @@ -1,34 +1,19 @@ { pkgs ? import { }, - vendorHash ? "sha256-xHScXL3i2oxJSJsvOC+KqLCA5Psu3ht7DQNrh0rB1rA=", + vendorHash ? "sha256-dWo8SAEUVyBhKyKoIj2u1VHiWPMod9veYGbivXkUI2Y=", }: let + sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key { }; +in +{ sops-install-secrets = pkgs.callPackage ./pkgs/sops-install-secrets { inherit vendorHash; }; -in -rec { - inherit sops-install-secrets; - sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key { }; + inherit sops-init-gpg-key; default = sops-init-gpg-key; sops-import-keys-hook = pkgs.callPackage ./pkgs/sops-import-keys-hook { }; # backwards compatibility inherit (pkgs) ssh-to-pgp; - - # used in the CI only - sops-pgp-hook-test = pkgs.callPackage ./pkgs/sops-pgp-hook-test.nix { - inherit vendorHash; - }; - unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { }; } -// (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { - lint = pkgs.callPackage ./pkgs/lint.nix { - inherit sops-install-secrets; - }; - - cross-build = pkgs.callPackage ./pkgs/cross-build.nix { - inherit sops-install-secrets; - }; -}) diff --git a/flake.nix b/flake.nix index b389c09f..1b95647c 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,18 @@ in { home-manager = self.legacyPackages.${system}.homeConfigurations.sops.activation-script; + treefmt = + (pkgs.callPackage ./formatter.nix { + inputs = privateInputs; + }).config.build.check; + + cross-build = pkgs.callPackage ./pkgs/cross-build.nix { + sops-install-secrets = self.packages.${system}.sops-install-secrets; + }; + + lint = pkgs.callPackage ./pkgs/lint.nix { + sops-install-secrets = self.packages.${system}.sops-install-secrets; + }; } // (suffix-stable packages-stable) // nixpkgs.lib.optionalAttrs pkgs.stdenv.isLinux tests @@ -131,6 +143,13 @@ } ); + formatter = eachSystem ( + { pkgs, ... }: + (pkgs.callPackage ./formatter.nix { + inputs = privateInputs; + }).config.build.wrapper + ); + apps = eachSystem ( { pkgs, ... }: { @@ -145,9 +164,11 @@ ); devShells = eachSystem ( - { pkgs, ... }: + { system, pkgs, ... }: { - unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { }; + unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { + sops-install-secrets = self.packages.${system}.sops-install-secrets; + }; default = pkgs.callPackage ./shell.nix { }; } ); diff --git a/formatter.nix b/formatter.nix new file mode 100644 index 00000000..4e6f13a0 --- /dev/null +++ b/formatter.nix @@ -0,0 +1,30 @@ +{ pkgs, inputs, ... }: +inputs.treefmt-nix.lib.evalModule pkgs { + projectRootFile = ".git/config"; + + programs = { + gofumpt.enable = true; + + nixfmt.enable = true; + + deadnix.enable = true; + deno.enable = true; + shellcheck.enable = true; + }; + + settings = { + global.excludes = [ + "./pkgs/sops-install-secrets/test-assets/*" + "*.narHash" + # unsupported extensions + "*.{asc,pub,gpg}" + "*/secrets.{bin,json,ini,yaml}" + ]; + + formatter = { + deadnix = { + priority = 1; + }; + }; + }; +} diff --git a/go.mod b/go.mod index 060734f9..9861c426 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Mic92/sops-nix -go 1.18 +go 1.22 require ( github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0 diff --git a/pkgs/sops-import-keys-hook/hook_test.go b/pkgs/sops-import-keys-hook/hook_test.go index 31e2fae8..35b0bff3 100644 --- a/pkgs/sops-import-keys-hook/hook_test.go +++ b/pkgs/sops-import-keys-hook/hook_test.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path" - "path/filepath" "runtime" "strings" "testing" @@ -14,22 +13,27 @@ import ( // ok fails the test if an err is not nil. func ok(tb testing.TB, err error) { + tb.Helper() + if err != nil { - _, file, line, _ := runtime.Caller(1) - fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + fmt.Printf("\033[31m unexpected error: %s\033[39m\n\n", err.Error()) tb.FailNow() } } func TestShellHook(t *testing.T) { + t.Parallel() + assets := os.Getenv("TEST_ASSETS") if assets == "" { _, filename, _, _ := runtime.Caller(0) assets = path.Join(path.Dir(filename), "test-assets") } + tempdir, err := os.MkdirTemp("", "testdir") ok(t, err) - cmd := exec.Command("cp", "-vra", assets+"/.", tempdir) // nolint:gosec + + cmd := exec.Command("cp", "-vra", assets+"/.", tempdir) //nolint:gosec fmt.Printf("$ %s\n", strings.Join(cmd.Args, " ")) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -37,12 +41,14 @@ func TestShellHook(t *testing.T) { defer os.RemoveAll(tempdir) - cmd = exec.Command("nix-shell", path.Join(assets, "shell.nix"), "--run", "gpg --list-keys") // nolint:gosec + cmd = exec.Command("nix-shell", path.Join(assets, "shell.nix"), "--run", "gpg --list-keys") //nolint:gosec + var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf cmd.Dir = tempdir fmt.Println(tempdir) + err = cmd.Run() stdout := stdoutBuf.String() stderr := stderrBuf.String() diff --git a/pkgs/sops-import-keys-hook/sops-import-keys-hook.bash b/pkgs/sops-import-keys-hook/sops-import-keys-hook.bash index 02246332..c05a96ea 100644 --- a/pkgs/sops-import-keys-hook/sops-import-keys-hook.bash +++ b/pkgs/sops-import-keys-hook/sops-import-keys-hook.bash @@ -2,7 +2,8 @@ sopsImportKeysHook() { local key dir if [ -n "${sopsCreateGPGHome}" ]; then export GNUPGHOME=${sopsGPGHome:-$(pwd)/.git/gnupg} - mkdir -m 700 -p $GNUPGHOME + # shellcheck disable=SC2174 + mkdir -m 700 -p "$GNUPGHOME" fi for key in ${sopsPGPKeys-}; do if [[ -f "$key" ]]; then diff --git a/pkgs/sops-install-secrets/.envrc b/pkgs/sops-install-secrets/.envrc deleted file mode 100644 index 1d953f4b..00000000 --- a/pkgs/sops-install-secrets/.envrc +++ /dev/null @@ -1 +0,0 @@ -use nix diff --git a/pkgs/sops-install-secrets/darwin.go b/pkgs/sops-install-secrets/darwin.go index 05cbf757..96d5baa1 100644 --- a/pkgs/sops-install-secrets/darwin.go +++ b/pkgs/sops-install-secrets/darwin.go @@ -23,7 +23,7 @@ func RuntimeDir() (string, error) { return strings.TrimSuffix(rundir, "/"), nil } -func SecureSymlinkChown(symlinkToCheck string, expectedTarget string, owner, group int) error { +func SecureSymlinkChown(symlinkToCheck string, expectedTarget string, owner, group uint32) error { // not sure what O_PATH is needed for anyways fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_SYMLINK|unix.O_NOFOLLOW, 0) if err != nil { @@ -39,7 +39,7 @@ func SecureSymlinkChown(symlinkToCheck string, expectedTarget string, owner, gro if n > len(expectedTarget) || string(buf[:n]) != expectedTarget { return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget) } - err = unix.Fchownat(fd, "", owner, group, unix.AT_SYMLINK_NOFOLLOW) + err = unix.Fchownat(fd, "", int(owner), int(group), unix.AT_SYMLINK_NOFOLLOW) if err != nil { return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err) } diff --git a/pkgs/sops-install-secrets/linux.go b/pkgs/sops-install-secrets/linux.go index b551e4b0..a4beebdb 100644 --- a/pkgs/sops-install-secrets/linux.go +++ b/pkgs/sops-install-secrets/linux.go @@ -4,6 +4,7 @@ package main import ( + "errors" "fmt" "os" @@ -13,12 +14,13 @@ import ( func RuntimeDir() (string, error) { rundir, ok := os.LookupEnv("XDG_RUNTIME_DIR") if !ok { - return "", fmt.Errorf("$XDG_RUNTIME_DIR is not set") + return "", errors.New("$XDG_RUNTIME_DIR is not set") } + return rundir, nil } -func SecureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) error { +func SecureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group uint32) error { // fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0) fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0) if err != nil { @@ -27,26 +29,32 @@ func SecureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) defer unix.Close(fd) buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc + n, err := unix.Readlinkat(fd, "", buf) if err != nil { return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck) } + if n > len(expectedTarget) || string(buf[:n]) != expectedTarget { return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget) } + stat := unix.Stat_t{} + err = unix.Fstat(fd, &stat) if err != nil { return fmt.Errorf("cannot stat '%s': %w", symlinkToCheck, err) } - if stat.Uid == uint32(owner) && stat.Gid == uint32(group) { + + if stat.Uid == owner && stat.Gid == group { return nil // already correct } - err = unix.Fchownat(fd, "", owner, group, unix.AT_EMPTY_PATH) + err = unix.Fchownat(fd, "", int(owner), int(group), unix.AT_EMPTY_PATH) if err != nil { return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err) } + return nil } @@ -60,8 +68,9 @@ func MountSecretFs(mountpoint string, keysGID int, useTmpfs bool, userMode bool) return nil } - var fstype = "ramfs" - var fsmagic = RamfsMagic + fstype := "ramfs" + fsmagic := RamfsMagic + if useTmpfs { fstype = "tmpfs" fsmagic = TmpfsMagic @@ -71,13 +80,14 @@ func MountSecretFs(mountpoint string, keysGID int, useTmpfs bool, userMode bool) if err := unix.Statfs(mountpoint, &buf); err != nil { return fmt.Errorf("cannot get statfs for directory '%s': %w", mountpoint, err) } - if int32(buf.Type) != fsmagic { + + if int32(buf.Type) != fsmagic { //nolint:gosec if err := unix.Mount("none", mountpoint, fstype, unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil { return fmt.Errorf("cannot mount: %w", err) } } - if err := os.Chown(mountpoint, 0, int(keysGID)); err != nil { + if err := os.Chown(mountpoint, 0, keysGID); err != nil { return fmt.Errorf("cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGID, err) } diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index 280df4c9..77654373 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -19,7 +19,6 @@ import ( "github.com/Mic92/sops-nix/pkgs/sops-install-secrets/sshkeys" agessh "github.com/Mic92/ssh-to-age" - "github.com/getsops/sops/v3/decrypt" "github.com/joho/godotenv" "github.com/mozilla-services/yaml" @@ -31,9 +30,9 @@ type secret struct { Key string `json:"key"` Path string `json:"path"` Owner *string `json:"owner,omitempty"` - UID int `json:"uid"` + UID uint32 `json:"uid"` Group *string `json:"group,omitempty"` - GID int `json:"gid"` + GID uint32 `json:"gid"` SopsFile string `json:"sopsFile"` Format FormatType `json:"format"` Mode string `json:"mode"` @@ -41,8 +40,8 @@ type secret struct { ReloadUnits []string `json:"reloadUnits"` value []byte mode os.FileMode - owner int - group int + owner uint32 + group uint32 } type loggingConfig struct { @@ -56,33 +55,33 @@ type template struct { Path string `json:"path"` Mode string `json:"mode"` Owner *string `json:"owner,omitempty"` - UID int `json:"uid"` + UID uint32 `json:"uid"` Group *string `json:"group,omitempty"` - GID int `json:"gid"` + GID uint32 `json:"gid"` File string `json:"file"` RestartUnits []string `json:"restartUnits"` ReloadUnits []string `json:"reloadUnits"` value []byte mode os.FileMode content string - owner int - group int + owner uint32 + group uint32 } type manifest struct { - Secrets []secret `json:"secrets"` - Templates []template `json:"templates"` - PlaceholderBySecretName map[string]string `json:"placeholderBySecretName"` - SecretsMountPoint string `json:"secretsMountPoint"` - SymlinkPath string `json:"symlinkPath"` - KeepGenerations int `json:"keepGenerations"` - SSHKeyPaths []string `json:"sshKeyPaths"` - GnupgHome string `json:"gnupgHome"` - AgeKeyFile string `json:"ageKeyFile"` - AgeSSHKeyPaths []string `json:"ageSshKeyPaths"` - UseTmpfs bool `json:"useTmpfs"` - UserMode bool `json:"userMode"` - Logging loggingConfig `json:"logging"` + Secrets []secret `json:"secrets"` + Templates []template `json:"templates"` + PlaceholderBySecretName map[string]string `json:"placeholderBySecretName"` + SecretsMountPoint string `json:"secretsMountPoint"` + SymlinkPath string `json:"symlinkPath"` + KeepGenerations int `json:"keepGenerations"` + SSHKeyPaths []string `json:"sshKeyPaths"` + GnupgHome string `json:"gnupgHome"` + AgeKeyFile string `json:"ageKeyFile"` + AgeSSHKeyPaths []string `json:"ageSshKeyPaths"` + UseTmpfs bool `json:"useTmpfs"` + UserMode bool `json:"userMode"` + Logging loggingConfig `json:"logging"` } type secretFile struct { @@ -92,7 +91,9 @@ type secretFile struct { firstSecret *secret } -type FormatType string +var isDryActivate = os.Getenv("NIXOS_ACTION") == "dry-activate" //nolint:gochecknoglobals + +type FormatType string //nolint:recvcheck const ( Yaml FormatType = "yaml" @@ -118,9 +119,11 @@ func IsValidFormat(format string) bool { func (f *FormatType) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err != nil { - return err + return fmt.Errorf("failed to unmarshal format: %w", err) } + t := FormatType(s) + switch t { case "": *f = Yaml @@ -132,7 +135,12 @@ func (f *FormatType) UnmarshalJSON(b []byte) error { } func (f FormatType) MarshalJSON() ([]byte, error) { - return json.Marshal(string(f)) + data, err := json.Marshal(string(f)) + if err != nil { + return nil, fmt.Errorf("failed to marshal format: %w", err) + } + + return data, nil } type CheckMode string @@ -165,52 +173,62 @@ func readManifest(path string) (*manifest, error) { if err != nil { return nil, fmt.Errorf("failed to open manifest: %w", err) } + defer file.Close() dec := json.NewDecoder(file) + var m manifest if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("failed to parse manifest: %w", err) } + return &m, nil } -func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, owner int, group int) bool { +func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, owner uint32, group uint32) bool { validUG := true if stat, ok := info.Sys().(*syscall.Stat_t); ok { - validUG = validUG && int(stat.Uid) == owner - validUG = validUG && int(stat.Gid) == group + validUG = validUG && stat.Uid == owner + validUG = validUG && stat.Gid == group } else { panic("Failed to cast fileInfo Sys() to *syscall.Stat_t. This is possibly an unsupported OS.") } + return linkTarget == targetFile && validUG } -func createSymlink(targetFile string, path string, owner int, group int, userMode bool) error { +func createSymlink(targetFile string, path string, owner uint32, group uint32, userMode bool) error { for { stat, err := os.Lstat(path) if os.IsNotExist(err) { if err = os.Symlink(targetFile, path); err != nil { return fmt.Errorf("cannot create symlink '%s': %w", path, err) } + if !userMode { if err = SecureSymlinkChown(path, targetFile, owner, group); err != nil { return fmt.Errorf("cannot chown symlink '%s': %w", path, err) } } + return nil } else if err != nil { return fmt.Errorf("cannot stat '%s': %w", path, err) } + if stat.Mode()&os.ModeSymlink == os.ModeSymlink { linkTarget, err := os.Readlink(path) - if os.IsNotExist(err) { + + switch { + case os.IsNotExist(err): continue - } else if err != nil { + case err != nil: return fmt.Errorf("cannot read symlink '%s': %w", path, err) - } else if linksAreEqual(linkTarget, targetFile, stat, owner, group) { + case linksAreEqual(linkTarget, targetFile, stat, owner, group): return nil } } + if err := os.Remove(path); err != nil { return fmt.Errorf("cannot override %s: %w", path, err) } @@ -223,10 +241,12 @@ func symlinkSecretsAndTemplates(targetDir string, secrets []secret, templates [] if targetFile == secret.Path { continue } + parent := filepath.Dir(secret.Path) if err := os.MkdirAll(parent, os.ModePerm); err != nil { return fmt.Errorf("cannot create parent directory of '%s': %w", secret.Path, err) } + if err := createSymlink(targetFile, secret.Path, secret.owner, secret.group, userMode); err != nil { return fmt.Errorf("failed to symlink secret '%s': %w", secret.Path, err) } @@ -237,10 +257,12 @@ func symlinkSecretsAndTemplates(targetDir string, secrets []secret, templates [] if targetFile == template.Path { continue } + parent := filepath.Dir(template.Path) if err := os.MkdirAll(parent, os.ModePerm); err != nil { return fmt.Errorf("cannot create parent directory of '%s': %w", template.Path, err) } + if err := createSymlink(targetFile, template.Path, template.owner, template.group, userMode); err != nil { return fmt.Errorf("failed to symlink template '%s': %w", template.Path, err) } @@ -256,7 +278,9 @@ type plainData struct { func recurseSecretKey(keys map[string]interface{}, wantedKey string) (string, error) { var val interface{} + var ok bool + currentKey := wantedKey currentData := keys keyUntilNow := "" @@ -270,29 +294,44 @@ func recurseSecretKey(keys map[string]interface{}, wantedKey string) (string, er if keyUntilNow != "" { keyUntilNow += "/" } + return "", fmt.Errorf("the key '%s%s' cannot be found", keyUntilNow, currentKey) } + break } + thisKey := currentKey[:slashIndex] + if keyUntilNow == "" { keyUntilNow = thisKey } else { keyUntilNow += "/" + thisKey } + currentKey = currentKey[(slashIndex + 1):] val, ok = currentData[thisKey] + if !ok { return "", fmt.Errorf("the key '%s' cannot be found", keyUntilNow) } + var valWithWrongType map[interface{}]interface{} valWithWrongType, ok = val.(map[interface{}]interface{}) + if !ok { return "", fmt.Errorf("key '%s' does not refer to a dictionary", keyUntilNow) } + currentData = make(map[string]interface{}) + for key, value := range valWithWrongType { - currentData[key.(string)] = value + keyStr, ok := key.(string) + if !ok { + return "", fmt.Errorf("the key '%s' is not a string", key) + } + + currentData[keyStr] = value } } @@ -300,6 +339,7 @@ func recurseSecretKey(keys map[string]interface{}, wantedKey string) (string, er if !ok { return "", fmt.Errorf("the value of key '%s' is not a string", keyUntilNow) } + return strVal, nil } @@ -319,7 +359,7 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error { sourceFile.binary = plain } else { if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil { - return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err) + return fmt.Errorf("cannot parse yaml of '%s': %w", s.SopsFile, err) } } case JSON: @@ -327,13 +367,14 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error { sourceFile.binary = plain } else { if err := json.Unmarshal(plain, &sourceFile.data); err != nil { - return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err) + return fmt.Errorf("cannot parse json of '%s': %w", s.SopsFile, err) } } default: return fmt.Errorf("secret of type %s in %s is not supported", s.Format, s.SopsFile) } } + switch s.Format { case Binary, Dotenv, Ini: s.value = sourceFile.binary @@ -345,10 +386,13 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error { if err != nil { return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err) } + s.value = []byte(strVal) } } + sourceFiles[s.SopsFile] = sourceFile + return nil } @@ -359,6 +403,7 @@ func decryptSecrets(secrets []secret) error { return err } } + return nil } @@ -369,10 +414,12 @@ const ( func prepareSecretsDir(secretMountpoint string, linkName string, keysGID int, userMode bool) (*string, error) { var generation uint64 + linkTarget, err := os.Readlink(linkName) if err == nil { if strings.HasPrefix(linkTarget, secretMountpoint) { targetBasename := filepath.Base(linkTarget) + generation, err = strconv.ParseUint(targetBasename, 10, 64) if err != nil { return nil, fmt.Errorf("cannot parse %s of %s as a number: %w", targetBasename, linkTarget, err) @@ -381,38 +428,46 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGID int, us } else if !os.IsNotExist(err) { return nil, fmt.Errorf("cannot access %s: %w", linkName, err) } + generation++ - dir := filepath.Join(secretMountpoint, strconv.Itoa(int(generation))) + dir := filepath.Join(secretMountpoint, strconv.FormatUint(generation, 10)) + if _, err := os.Stat(dir); !os.IsNotExist(err) { if err := os.RemoveAll(dir); err != nil { return nil, fmt.Errorf("cannot remove existing %s: %w", dir, err) } } + if err := os.Mkdir(dir, os.FileMode(0o751)); err != nil { return nil, fmt.Errorf("mkdir(): %w", err) } + if !userMode { - if err := os.Chown(dir, 0, int(keysGID)); err != nil { + if err := os.Chown(dir, 0, keysGID); err != nil { return nil, fmt.Errorf("cannot change owner/group of '%s' to 0/%d: %w", dir, keysGID, err) } } + return &dir, nil } func createParentDirs(parent string, target string, keysGID int, userMode bool) error { dirs := strings.Split(filepath.Dir(target), "/") pathSoFar := parent + for _, dir := range dirs { pathSoFar = filepath.Join(pathSoFar, dir) if err := os.MkdirAll(pathSoFar, 0o751); err != nil { return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) } + if !userMode { - if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil { + if err := os.Chown(pathSoFar, 0, keysGID); err != nil { return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) } } } + return nil } @@ -423,15 +478,18 @@ func writeSecrets(secretDir string, secrets []secret, keysGID int, userMode bool if err := createParentDirs(secretDir, secret.Name, keysGID, userMode); err != nil { return err } - if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil { + + if err := os.WriteFile(fp, secret.value, secret.mode); err != nil { return fmt.Errorf("cannot write %s: %w", fp, err) } + if !userMode { - if err := os.Chown(fp, secret.owner, secret.group); err != nil { + if err := os.Chown(fp, int(secret.owner), int(secret.group)); err != nil { return fmt.Errorf("cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err) } } } + return nil } @@ -440,10 +498,12 @@ func lookupGroup(groupname string) (int, error) { if err != nil { return 0, fmt.Errorf("failed to lookup 'keys' group: %w", err) } + gid, err := strconv.ParseInt(group.Gid, 10, 64) if err != nil { return 0, fmt.Errorf("cannot parse keys gid %s: %w", group.Gid, err) } + return int(gid), nil } @@ -452,10 +512,12 @@ func lookupKeysGroup() (int, error) { if err1 == nil { return gid, nil } + gid, err2 := lookupGroup("nogroup") if err2 == nil { return gid, nil } + return 0, fmt.Errorf("can't find group 'keys' nor 'nogroup' (%w)", err2) } @@ -476,6 +538,7 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) { if err := json.Unmarshal(cipherText, &keys); err != nil { return nil, fmt.Errorf("cannot parse json of '%s': %w", s.SopsFile, err) } + return &secretFile{cipherText: cipherText, firstSecret: s}, nil case Yaml: if err := yaml.Unmarshal(cipherText, &keys); err != nil { @@ -486,7 +549,9 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) { if err != nil { return nil, fmt.Errorf("cannot parse dotenv of '%s': %w", s.SopsFile, err) } + keys = map[string]interface{}{} + for k, v := range env { keys[k] = v } @@ -495,11 +560,11 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) { return nil, fmt.Errorf("cannot parse json of '%s': %w", s.SopsFile, err) } case Ini: + // TODO: we do not actually check the contents of the ini here... _, err := ini.Load(bytes.NewReader(cipherText)) if err != nil { return nil, fmt.Errorf("cannot parse ini of '%s': %w", s.SopsFile, err) } - // TODO: we do not actually check the contents of the ini here... } return &secretFile{ @@ -515,12 +580,14 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error { s.Name, s.SopsFile, s.Format, file.firstSecret.Format, file.firstSecret.Name) } + if app.checkMode != Manifest && !(s.Format == Binary || s.Format == Dotenv || s.Format == Ini) && s.Key != "" { _, err := recurseSecretKey(file.keys, s.Key) if err != nil { return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err) } } + return nil } @@ -529,31 +596,36 @@ func validateMode(mode string) (os.FileMode, error) { if err != nil { return 0, fmt.Errorf("invalid number in mode: %s: %w", mode, err) } + return os.FileMode(parsed), nil } -func validateOwner(owner string) (int, error) { +func validateOwner(owner string) (uint32, error) { lookedUp, err := user.Lookup(owner) if err != nil { return 0, fmt.Errorf("failed to lookup user '%s': %w", owner, err) } - ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 64) + + ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 32) if err != nil { return 0, fmt.Errorf("cannot parse uid %s: %w", lookedUp.Uid, err) } - return int(ownerNr), nil + + return uint32(ownerNr), nil } -func validateGroup(group string) (int, error) { +func validateGroup(group string) (uint32, error) { lookedUp, err := user.LookupGroup(group) if err != nil { return 0, fmt.Errorf("failed to lookup group '%s': %w", group, err) } - groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 64) + + groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 32) if err != nil { return 0, fmt.Errorf("cannot parse gid %s: %w", lookedUp.Gid, err) } - return int(groupNr), nil + + return uint32(groupNr), nil } func (app *appContext) validateSecret(secret *secret) error { @@ -561,9 +633,10 @@ func (app *appContext) validateSecret(secret *secret) error { if err != nil { return err } + secret.mode = mode - if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" { + if app.ignorePasswd || isDryActivate { secret.owner = 0 secret.group = 0 } else if app.checkMode == Off || app.ignorePasswd { @@ -574,6 +647,7 @@ func (app *appContext) validateSecret(secret *secret) error { if err != nil { return err } + secret.owner = owner } @@ -584,6 +658,7 @@ func (app *appContext) validateSecret(secret *secret) error { if err != nil { return err } + secret.group = group } } @@ -602,6 +677,7 @@ func (app *appContext) validateSecret(secret *secret) error { if err != nil { return err } + app.secretFiles[secret.SopsFile] = *maybeFile file = *maybeFile @@ -623,6 +699,7 @@ func renderTemplate(content *string, secretByPlaceholder map[string]*secret) str for placeholder, secret := range secretByPlaceholder { rendered = strings.ReplaceAll(rendered, placeholder, string(secret.value)) } + return rendered } @@ -631,9 +708,10 @@ func (app *appContext) validateTemplate(template *template) error { if err != nil { return err } + template.mode = mode - if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" { + if app.ignorePasswd || isDryActivate { template.owner = 0 template.group = 0 } else if app.checkMode == Off || app.ignorePasswd { @@ -644,6 +722,7 @@ func (app *appContext) validateTemplate(template *template) error { if err != nil { return err } + template.owner = owner } @@ -654,25 +733,25 @@ func (app *appContext) validateTemplate(template *template) error { if err != nil { return err } + template.group = group } } - var templateText string - if template.Content != "" { - templateText = template.Content - } else if template.File != "" { + switch { + case template.Content != "": + template.content = template.Content + case template.File != "": templateBytes, err := os.ReadFile(template.File) if err != nil { return fmt.Errorf("cannot read %s: %w", template.File, err) } - templateText = string(templateBytes) - } else { + + template.content = string(templateBytes) + _: return fmt.Errorf("neither content nor file was specified for template %s", template.Name) } - template.content = templateText - return nil } @@ -684,6 +763,7 @@ func (app *appContext) validateManifest() error { if len(m.SSHKeyPaths) > 0 { return fmt.Errorf(errorFmt, "sshKeyPaths") } + if m.AgeKeyFile != "" { return fmt.Errorf(errorFmt, "ageKeyFile") } @@ -709,27 +789,30 @@ func (app *appContext) validateManifest() error { return err } } + return nil } func atomicSymlink(oldname, newname string) error { if err := os.MkdirAll(filepath.Dir(newname), 0o755); err != nil { - return err + return fmt.Errorf("cannot create directory for %s: %w", newname, err) } // Fast path: if newname does not exist yet, we can skip the whole dance // below. if err := os.Symlink(oldname, newname); err == nil || !os.IsExist(err) { - return err + return fmt.Errorf("cannot create symlink %s -> %s: %w", newname, oldname, err) } // We need to use ioutil.TempDir, as we cannot overwrite a ioutil.TempFile, // and removing+symlinking creates a TOCTOU race. d, err := os.MkdirTemp(filepath.Dir(newname), "."+filepath.Base(newname)) if err != nil { - return err + return fmt.Errorf("cannot create temporary directory: %w", err) } + cleanup := true + defer func() { if cleanup { os.RemoveAll(d) @@ -738,15 +821,21 @@ func atomicSymlink(oldname, newname string) error { symlink := filepath.Join(d, "tmp.symlink") if err := os.Symlink(oldname, symlink); err != nil { - return err + return fmt.Errorf("cannot create temporary symlink %s -> %s: %w", symlink, oldname, err) } if err := os.Rename(symlink, newname); err != nil { - return err + return fmt.Errorf("cannot rename %s to %s: %w", symlink, newname, err) } cleanup = false - return os.RemoveAll(d) + + err = os.RemoveAll(d) + if err != nil { + return fmt.Errorf("cannot remove temporary directory: %w", err) + } + + return nil } func pruneGenerations(secretsMountPoint, secretsDir string, keepGenerations int) error { @@ -771,6 +860,7 @@ func pruneGenerations(secretsMountPoint, secretsDir string, keepGenerations int) if err != nil { return fmt.Errorf("cannot read %s: %w", secretsMountPoint, err) } + for _, generationName := range generations { generationNum, err := strconv.Atoi(generationName) // Not a number? Not relevant @@ -782,6 +872,7 @@ func pruneGenerations(secretsMountPoint, secretsDir string, keepGenerations int) if generationNum == currentGeneration { continue } + if currentGeneration-keepGenerations >= generationNum { os.RemoveAll(path.Join(secretsMountPoint, generationName)) } @@ -810,60 +901,67 @@ func importSSHKeys(logcfg loggingConfig, keyPaths []string, gpgHome string) erro sshKey, err := os.ReadFile(p) if err != nil { fmt.Fprintf(os.Stderr, "Cannot read ssh key '%s': %s\n", p, err) + continue } + gpgKey, err := sshkeys.SSHPrivateKeyToPGP(sshKey) if err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) + continue } if err := gpgKey.SerializePrivate(secring, nil); err != nil { fmt.Fprintf(os.Stderr, "Cannot write secring: %s\n", err) + continue } if err := gpgKey.Serialize(pubring); err != nil { fmt.Fprintf(os.Stderr, "Cannot write pubring: %s\n", err) + continue } if logcfg.KeyImport { - fmt.Printf("%s: Imported %s as GPG key with fingerprint %s\n", path.Base(os.Args[0]), p, hex.EncodeToString(gpgKey.PrimaryKey.Fingerprint[:])) + fmt.Printf("%s: Imported %s as GPG key with fingerprint %s\n", path.Base(os.Args[0]), p, hex.EncodeToString(gpgKey.PrimaryKey.Fingerprint)) } } return nil } -func importAgeSSHKeys(logcfg loggingConfig, keyPaths []string, ageFile os.File) error { +func importAgeSSHKeys(logcfg loggingConfig, keyPaths []string, ageFile os.File) { for _, p := range keyPaths { // Read the key sshKey, err := os.ReadFile(p) if err != nil { fmt.Fprintf(os.Stderr, "Cannot read ssh key '%s': %s\n", p, err) + continue } // Convert the key to age privKey, pubKey, err := agessh.SSHPrivateKeyToAge(sshKey, []byte{}) if err != nil { fmt.Fprintf(os.Stderr, "Cannot convert ssh key '%s': %s\n", p, err) + continue } // Append it to the file _, err = ageFile.WriteString(*privKey + "\n") if err != nil { fmt.Fprintf(os.Stderr, "Cannot write key to age file: %s\n", err) + continue } if logcfg.KeyImport { fmt.Fprintf(os.Stderr, "%s: Imported %s as age key with fingerprint %s\n", path.Base(os.Args[0]), p, *pubKey) + continue } } - - return nil } // Like filepath.Walk but symlink-aware. @@ -873,30 +971,39 @@ func symlinkWalk(filename string, linkDirname string, walkFn filepath.WalkFunc) if fname, err := filepath.Rel(filename, path); err == nil { path = filepath.Join(linkDirname, fname) } else { - return err + return fmt.Errorf("cannot get relative path: %w", err) } if err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink { finalPath, err := filepath.EvalSymlinks(path) if err != nil { - return err + return fmt.Errorf("cannot evaluate symlink %s: %w", path, err) } - info, err := os.Lstat(finalPath) + + linkInfo, err := os.Lstat(finalPath) if err != nil { - return walkFn(path, info, err) + return walkFn(path, linkInfo, err) } - if info.IsDir() { + + if linkInfo.IsDir() { return symlinkWalk(finalPath, path, walkFn) } } return walkFn(path, info, err) } - return filepath.Walk(filename, symWalkFunc) + + err := filepath.Walk(filename, symWalkFunc) + if err != nil { + return fmt.Errorf("error walking %s: %w", filename, err) + } + + return nil } func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret, templates []template) error { var restart []string + var reload []string newSecrets := make(map[string]bool) @@ -927,15 +1034,17 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s restart = append(restart, secret.RestartUnits...) reload = append(reload, secret.ReloadUnits...) newSecrets[secret.Name] = true + continue } - return err + + return fmt.Errorf("cannot read %s: %w", oldPath, err) } // Read the new file newData, err := os.ReadFile(newPath) if err != nil { - return err + return fmt.Errorf("cannot read %s: %w", newPath, err) } if !bytes.Equal(oldData, newData) { @@ -958,15 +1067,17 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s restart = append(restart, template.RestartUnits...) reload = append(reload, template.ReloadUnits...) newTemplates[template.Name] = true + continue } - return err + + return fmt.Errorf("cannot read %s: %w", oldPath, err) } // Read the new file newData, err := os.ReadFile(newPath) if err != nil { - return err + return fmt.Errorf("cannot read %s: %w", newPath, err) } if !bytes.Equal(oldData, newData) { @@ -981,32 +1092,39 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s if _, err := os.Stat(filepath.Dir(file)); err != nil { if os.IsNotExist(err) { return nil - } else { - return err } + + return fmt.Errorf("cannot stat %s: %w", filepath.Dir(file), err) } + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { - return err + return fmt.Errorf("cannot open %s: %w", file, err) } + defer f.Close() + for _, unit := range list { if _, err = f.WriteString(unit + "\n"); err != nil { - return err + return fmt.Errorf("cannot write to %s: %w", file, err) } } } + return nil } + var dryPrefix string if isDry { dryPrefix = "/run/nixos/dry-activation" } else { dryPrefix = "/run/nixos/activation" } + if err := writeLines(restart, dryPrefix+"-restart-list"); err != nil { return err } + if err := writeLines(reload, dryPrefix+"-reload-list"); err != nil { return err } @@ -1018,10 +1136,12 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s // Find removed secrets/templates. symlinkRenderedPath := filepath.Join(symlinkPath, RenderedSubdir) + err := symlinkWalk(symlinkPath, symlinkPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } + if info.IsDir() { return nil } @@ -1030,8 +1150,9 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s // it's a secret. rel, err := filepath.Rel(symlinkRenderedPath, path) if err != nil { - return err + return fmt.Errorf("cannot get relative path: %w", err) } + isSecret := strings.HasPrefix(rel, "..") if isSecret { @@ -1041,6 +1162,7 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s return nil } } + removedSecrets[path] = true } else { path = strings.TrimPrefix(path, symlinkRenderedPath+string(os.PathSeparator)) @@ -1049,8 +1171,10 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s return nil } } + removedTemplates[path] = true } + return nil }) if err != nil { @@ -1064,6 +1188,7 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s if len(changed) != 1 { s = "s" } + if isDry { fmt.Printf("%s %s%s: ", dryPrefix, noun, s) } else { @@ -1075,6 +1200,7 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s for key := range changed { keys = append(keys, key) } + sort.Strings(keys) fmt.Println(strings.Join(keys, ", ")) @@ -1104,12 +1230,15 @@ func setupGPGKeyring(logcfg loggingConfig, sshKeys []string, parentDir string) ( if err != nil { return nil, fmt.Errorf("cannot create gpg home in '%s': %w", parentDir, err) } + k := keyring{dir} if err := importSSHKeys(logcfg, sshKeys, dir); err != nil { os.RemoveAll(dir) + return nil, err } + os.Setenv("GNUPGHOME", dir) return &k, nil @@ -1117,16 +1246,20 @@ func setupGPGKeyring(logcfg loggingConfig, sshKeys []string, parentDir string) ( func parseFlags(args []string) (*options, error) { var opts options + fs := flag.NewFlagSet(args[0], flag.ContinueOnError) fs.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [OPTION] manifest.json\n", args[0]) fs.PrintDefaults() } + var checkMode string + fs.StringVar(&checkMode, "check-mode", "off", `Validate configuration without installing it (possible values: "manifest","sopsfile","off")`) fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root or the user executing the tool in user mode`) + if err := fs.Parse(args[1:]); err != nil { - return nil, err + return nil, fmt.Errorf("error parsing flags: %w", err) } switch CheckMode(checkMode) { @@ -1138,23 +1271,30 @@ func parseFlags(args []string) (*options, error) { if fs.NArg() != 1 { flag.Usage() + return nil, flag.ErrHelp } + opts.manifest = fs.Arg(0) + return &opts, nil } -func replaceRuntimeDir(path, rundir string) (ret string) { +func replaceRuntimeDir(path, rundir string) string { parts := strings.Split(path, "%%") first := true + ret := "" + for _, part := range parts { if !first { ret += "%" } + first = false ret += strings.ReplaceAll(part, "%r", rundir) } - return + + return ret } func writeTemplates(targetDir string, templates []template, keysGID int, userMode bool) error { @@ -1165,15 +1305,17 @@ func writeTemplates(targetDir string, templates []template, keysGID int, userMod return err } - if err := os.WriteFile(fp, []byte(template.value), template.mode); err != nil { + if err := os.WriteFile(fp, template.value, template.mode); err != nil { return fmt.Errorf("cannot write %s: %w", fp, err) } + if !userMode { - if err := os.Chown(fp, template.owner, template.group); err != nil { + if err := os.Chown(fp, int(template.owner), int(template.group)); err != nil { return fmt.Errorf("cannot change owner/group of '%s' to %d/%d: %w", fp, template.owner, template.group, err) } } } + return nil } @@ -1191,16 +1333,21 @@ func installSecrets(args []string) error { if manifest.UserMode { var rundir string rundir, err = RuntimeDir() + if opts.checkMode == Off && err != nil { return fmt.Errorf("cannot figure out runtime directory: %w", err) } + manifest.SecretsMountPoint = replaceRuntimeDir(manifest.SecretsMountPoint, rundir) manifest.SymlinkPath = replaceRuntimeDir(manifest.SymlinkPath, rundir) + var newSecrets []secret + for _, secret := range manifest.Secrets { secret.Path = replaceRuntimeDir(secret.Path, rundir) newSecrets = append(newSecrets, secret) } + manifest.Secrets = newSecrets } @@ -1230,18 +1377,18 @@ func installSecrets(args []string) error { } } - isDry := os.Getenv("NIXOS_ACTION") == "dry-activate" - if err = MountSecretFs(manifest.SecretsMountPoint, keysGID, manifest.UseTmpfs, manifest.UserMode); err != nil { return fmt.Errorf("failed to mount filesystem for secrets: %w", err) } if len(manifest.SSHKeyPaths) != 0 { var keyring *keyring + keyring, err = setupGPGKeyring(manifest.Logging, manifest.SSHKeyPaths, manifest.SecretsMountPoint) if err != nil { return fmt.Errorf("error setting up gpg keyring: %w", err) } + defer keyring.Remove() } else if manifest.GnupgHome != "" { os.Setenv("GNUPGHOME", manifest.GnupgHome) @@ -1253,28 +1400,30 @@ func installSecrets(args []string) error { os.Setenv("SOPS_AGE_KEY_FILE", keyfile) // Create the keyfile var ageFile *os.File + ageFile, err = os.OpenFile(keyfile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("cannot create '%s': %w", keyfile, err) } + defer ageFile.Close() + fmt.Fprintf(ageFile, "# generated by sops-nix at %s\n", time.Now().Format(time.RFC3339)) // Import SSH keys if len(manifest.AgeSSHKeyPaths) != 0 { - err = importAgeSSHKeys(manifest.Logging, manifest.AgeSSHKeyPaths, *ageFile) - if err != nil { - return err - } + importAgeSSHKeys(manifest.Logging, manifest.AgeSSHKeyPaths, *ageFile) } // Import the keyfile if manifest.AgeKeyFile != "" { // Read the keyfile var contents []byte + contents, err = os.ReadFile(manifest.AgeKeyFile) if err != nil { return fmt.Errorf("cannot read keyfile '%s': %w", manifest.AgeKeyFile, err) } + // Append it to the file _, err = ageFile.WriteString(string(contents) + "\n") if err != nil { @@ -1294,6 +1443,7 @@ func installSecrets(args []string) error { if err != nil { return fmt.Errorf("failed to prepare new secrets directory: %w", err) } + if err := writeSecrets(*secretDir, manifest.Secrets, keysGID, manifest.UserMode); err != nil { return fmt.Errorf("cannot write secrets: %w", err) } @@ -1303,20 +1453,23 @@ func installSecrets(args []string) error { } if !manifest.UserMode { - if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets, manifest.Templates); err != nil { + if err := handleModifications(isDryActivate, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets, manifest.Templates); err != nil { return fmt.Errorf("cannot request units to restart: %w", err) } } // No need to perform the actual symlinking - if isDry { + if isDryActivate { return nil } + if err := symlinkSecretsAndTemplates(manifest.SymlinkPath, manifest.Secrets, manifest.Templates, manifest.UserMode); err != nil { return fmt.Errorf("failed to prepare symlinks to secret store: %w", err) } + if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil { return fmt.Errorf("cannot update secrets symlink: %w", err) } + if err := pruneGenerations(manifest.SecretsMountPoint, *secretDir, manifest.KeepGenerations); err != nil { return fmt.Errorf("cannot prune old secrets generations: %w", err) } @@ -1329,6 +1482,7 @@ func main() { if errors.Is(err, flag.ErrHelp) { return } + fmt.Fprintf(os.Stderr, "%s: %s\n", os.Args[0], err) os.Exit(1) } diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go index 8c09e68c..595d6b15 100644 --- a/pkgs/sops-install-secrets/main_test.go +++ b/pkgs/sops-install-secrets/main_test.go @@ -10,7 +10,6 @@ import ( "os/exec" "os/user" "path" - "path/filepath" "reflect" "runtime" "strconv" @@ -19,30 +18,41 @@ import ( "testing" ) +const ( + NOBODY = "nobody" + NOGROUP = "nogroup" +) + // ok fails the test if an err is not nil. func ok(tb testing.TB, err error) { + tb.Helper() + if err != nil { - _, file, line, _ := runtime.Caller(1) - fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + fmt.Printf("\033[31munexpected error: %s\033[39m\n\n", err.Error()) tb.FailNow() } } func equals(tb testing.TB, exp, act interface{}) { + tb.Helper() + if !reflect.DeepEqual(exp, act) { - _, file, line, _ := runtime.Caller(1) - fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + fmt.Printf("\033[31m\texp: %#v\n\n\tgot: %#v\033[39m\n\n", exp, act) tb.FailNow() } } func writeManifest(t *testing.T, dir string, m *manifest) string { + t.Helper() + filename := path.Join(dir, "manifest.json") f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0o755) ok(t, err) + encoder := json.NewEncoder(f) ok(t, encoder.Encode(m)) f.Close() + return filename } @@ -51,7 +61,9 @@ func testAssetPath() string { if assets != "" { return assets } + _, filename, _, _ := runtime.Caller(0) + return path.Join(path.Dir(filename), "test-assets") } @@ -64,43 +76,52 @@ func (dir testDir) Remove() { } func newTestDir(t *testing.T) testDir { + t.Helper() + tempdir, err := os.MkdirTemp("", "symlinkDir") ok(t, err) + return testDir{tempdir, path.Join(tempdir, "secrets.d"), path.Join(tempdir, "secrets")} } func testInstallSecret(t *testing.T, testdir testDir, m *manifest) { + t.Helper() + path := writeManifest(t, testdir.path, m) ok(t, installSecrets([]string{"sops-install-secrets", path})) } -func testGPG(t *testing.T) { +// cannot run in parellel with TestSSHKey because we rely on GNUPGHOME environment variable +func TestGPG(t *testing.T) { //nolint:paralleltest assets := testAssetPath() testdir := newTestDir(t) defer testdir.Remove() gpgHome := path.Join(testdir.path, "gpg-home") - gpgEnv := append(os.Environ(), fmt.Sprintf("GNUPGHOME=%s", gpgHome)) + gpgEnv := append(os.Environ(), "GNUPGHOME="+gpgHome) ok(t, os.Mkdir(gpgHome, os.FileMode(0o700))) - cmd := exec.Command("gpg", "--import", path.Join(assets, "key.asc")) // nolint:gosec + + cmd := exec.Command("gpg", "--import", path.Join(assets, "key.asc")) //nolint:gosec cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = gpgEnv ok(t, cmd.Run()) + stopGpgCmd := exec.Command("gpgconf", "--kill", "gpg-agent") stopGpgCmd.Stdout = os.Stdout stopGpgCmd.Stderr = os.Stderr stopGpgCmd.Env = gpgEnv + defer func() { if err := stopGpgCmd.Run(); err != nil { fmt.Printf("failed to stop gpg-agent: %s\n", err) } }() - nobody := "nobody" - nogroup := "nogroup" // should create a symlink + nobody := NOBODY + nogroup := NOGROUP yamlSecret := secret{ Name: "test", Key: "test_key", @@ -114,6 +135,7 @@ func testGPG(t *testing.T) { } var jsonSecret, binarySecret, dotenvSecret, iniSecret secret + root := "root" // should not create a symlink jsonSecret = yamlSecret @@ -147,19 +169,19 @@ func testGPG(t *testing.T) { iniSecret.SopsFile = path.Join(assets, "secrets.ini") iniSecret.Path = path.Join(testdir.secretsPath, "test5") - manifest := manifest{ + m := manifest{ Secrets: []secret{yamlSecret, jsonSecret, binarySecret, dotenvSecret, iniSecret}, SecretsMountPoint: testdir.secretsPath, SymlinkPath: testdir.symlinkPath, GnupgHome: gpgHome, } - testInstallSecret(t, testdir, &manifest) + testInstallSecret(t, testdir, &m) - _, err := os.Stat(manifest.SecretsMountPoint) + _, err := os.Stat(m.SecretsMountPoint) ok(t, err) - _, err = os.Stat(manifest.SymlinkPath) + _, err = os.Stat(m.SymlinkPath) ok(t, err) yamlLinkStat, err := os.Lstat(yamlSecret.Path) @@ -174,22 +196,24 @@ func testGPG(t *testing.T) { equals(t, 0o400, int(yamlStat.Mode().Perm())) stat, success := yamlStat.Sys().(*syscall.Stat_t) equals(t, true, success) + content, err := os.ReadFile(yamlSecret.Path) ok(t, err) equals(t, "test_value", string(content)) u, err := user.LookupId(strconv.Itoa(int(stat.Uid))) ok(t, err) - equals(t, "nobody", u.Username) + equals(t, NOBODY, u.Username) g, err := user.LookupGroupId(strconv.Itoa(int(stat.Gid))) ok(t, err) - equals(t, "nogroup", g.Name) + equals(t, NOGROUP, g.Name) jsonStat, err := os.Stat(jsonSecret.Path) ok(t, err) equals(t, true, jsonStat.Mode().IsRegular()) equals(t, 0o700, int(jsonStat.Mode().Perm())) + if stat, ok := jsonStat.Sys().(*syscall.Stat_t); ok { equals(t, 0, int(stat.Uid)) equals(t, 0, int(stat.Gid)) @@ -199,14 +223,16 @@ func testGPG(t *testing.T) { ok(t, err) equals(t, 13, len(content)) - testInstallSecret(t, testdir, &manifest) + testInstallSecret(t, testdir, &m) target, err := os.Readlink(testdir.symlinkPath) ok(t, err) equals(t, path.Join(testdir.secretsPath, "2"), target) } -func testSSHKey(t *testing.T) { +func TestSSHKey(t *testing.T) { + t.Parallel() + assets := testAssetPath() testdir := newTestDir(t) @@ -217,8 +243,8 @@ func testSSHKey(t *testing.T) { ok(t, err) file.Close() - nobody := "nobody" - nogroup := "nogroup" + nobody := NOBODY + nogroup := NOGROUP s := secret{ Name: "test", Key: "test_key", @@ -242,6 +268,8 @@ func testSSHKey(t *testing.T) { } func TestAge(t *testing.T) { + t.Parallel() + assets := testAssetPath() testdir := newTestDir(t) @@ -252,8 +280,8 @@ func TestAge(t *testing.T) { ok(t, err) file.Close() - nobody := "nobody" - nogroup := "nogroup" + nobody := NOBODY + nogroup := NOGROUP s := secret{ Name: "test", Key: "test_key", @@ -277,6 +305,8 @@ func TestAge(t *testing.T) { } func TestAgeWithSSH(t *testing.T) { + t.Parallel() + assets := testAssetPath() testdir := newTestDir(t) @@ -287,8 +317,8 @@ func TestAgeWithSSH(t *testing.T) { ok(t, err) file.Close() - nobody := "nobody" - nogroup := "nogroup" + nobody := NOBODY + nogroup := NOGROUP s := secret{ Name: "test", Key: "test_key", @@ -311,20 +341,16 @@ func TestAgeWithSSH(t *testing.T) { testInstallSecret(t, testdir, &m) } -func TestAll(t *testing.T) { - // we can't test in parallel because we rely on GNUPGHOME environment variable - testGPG(t) - testSSHKey(t) -} - func TestValidateManifest(t *testing.T) { + t.Parallel() + assets := testAssetPath() testdir := newTestDir(t) defer testdir.Remove() - nobody := "nobody" - nogroup := "nogroup" + nobody := NOBODY + nogroup := NOGROUP s := secret{ Name: "test", Key: "test_key", @@ -351,6 +377,8 @@ func TestValidateManifest(t *testing.T) { } func TestIsValidFormat(t *testing.T) { + t.Parallel() + generateCase := func(input string, mustBe bool) { result := IsValidFormat(input) if result != mustBe { diff --git a/pkgs/sops-install-secrets/shell.nix b/pkgs/sops-install-secrets/shell.nix deleted file mode 100644 index 10e4e813..00000000 --- a/pkgs/sops-install-secrets/shell.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - pkgs ? import { }, -}: -pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - go - delve - util-linux - gnupg - ]; -} diff --git a/pkgs/sops-install-secrets/sshkeys/convert.go b/pkgs/sops-install-secrets/sshkeys/convert.go index 070cf1e4..6935f526 100644 --- a/pkgs/sops-install-secrets/sshkeys/convert.go +++ b/pkgs/sops-install-secrets/sshkeys/convert.go @@ -15,7 +15,7 @@ import ( func parsePrivateKey(sshPrivateKey []byte) (*rsa.PrivateKey, error) { privateKey, err := ssh.ParseRawPrivateKey(sshPrivateKey) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse private ssh key: %w", err) } rsaKey, ok := privateKey.(*rsa.PrivateKey) @@ -60,9 +60,10 @@ func SSHPrivateKeyToPGP(sshPrivateKey []byte) (*openpgp.Entity, error) { IssuerKeyId: &gpgKey.PrimaryKey.KeyId, }, } + err = gpgKey.Identities[uid.Id].SelfSignature.SignUserId(uid.Id, gpgKey.PrimaryKey, gpgKey.PrivateKey, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to sign user id: %w", err) } return gpgKey, nil diff --git a/pkgs/sops-pgp-hook-test.nix b/pkgs/sops-pgp-hook-test.nix deleted file mode 100644 index 7f9f7df3..00000000 --- a/pkgs/sops-pgp-hook-test.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ buildGoModule, vendorHash }: - -buildGoModule { - name = "sops-pgp-hook-test"; - src = ../.; - inherit vendorHash; - buildPhase = '' - go test -c ./pkgs/sops-pgp-hook - install -D sops-pgp-hook.test $out/bin/sops-pgp-hook.test - ''; -} diff --git a/pkgs/sops-pgp-hook/default.nix b/pkgs/sops-pgp-hook/default.nix deleted file mode 100644 index 300b3c46..00000000 --- a/pkgs/sops-pgp-hook/default.nix +++ /dev/null @@ -1,25 +0,0 @@ -{ - makeSetupHook, - gnupg, - sops, - lib, -}: - -let - # FIXME: drop after 23.05 - propagatedBuildInputs = - if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then - "deps" - else - "propagatedBuildInputs"; -in -(makeSetupHook { - name = "sops-pgp-hook"; - substitutions = { - gpg = "${gnupg}/bin/gpg"; - }; - ${propagatedBuildInputs} = [ - sops - gnupg - ]; -} ./sops-pgp-hook.bash) diff --git a/pkgs/sops-pgp-hook/hook_test.go b/pkgs/sops-pgp-hook/hook_test.go deleted file mode 100644 index acf6b1d7..00000000 --- a/pkgs/sops-pgp-hook/hook_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// ok fails the test if an err is not nil. -func ok(tb testing.TB, err error) { - if err != nil { - _, file, line, _ := runtime.Caller(1) - fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) - tb.FailNow() - } -} - -func TestShellHook(t *testing.T) { - assets := os.Getenv("TEST_ASSETS") - if assets == "" { - _, filename, _, _ := runtime.Caller(0) - assets = path.Join(path.Dir(filename), "test-assets") - } - tempdir, err := os.MkdirTemp("", "testdir") - ok(t, err) - defer os.RemoveAll(tempdir) - - cmd := exec.Command("nix-shell", "shell.nix", "--run", "echo SOPS_PGP_FP=$SOPS_PGP_FP") - cmd.Env = append(os.Environ(), fmt.Sprintf("GNUPGHOME=%s", tempdir)) - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - cmd.Dir = assets - err = cmd.Run() - stdout := stdoutBuf.String() - stderr := stderrBuf.String() - fmt.Printf("$ %s\nstdout: \n%s\nstderr: \n%s\n", strings.Join(cmd.Args, " "), stdout, stderr) - ok(t, err) - - expectedKeys := []string{ - "C6DA56E69A7C756564A8AFEB4A6B05B714D13EFD", - "4EC40F8E04A945339F7F7C0032C5225271038E3F", - "7FB89715AADA920D65D25E63F9BA9DEBD03F57C0", - "E3B7464FBE89F5378ED4BC60FC925B42FC8B773D", - } - for _, key := range expectedKeys { - if !strings.Contains(stdout, key) { - t.Fatalf("'%v' not in '%v'", key, stdout) - } - } - - // it should ignore subkeys from ./keys/key-with-subkeys.asc - subkey := "94F174F588090494E73D0835A79B1680BC4D9A54" - if strings.Contains(stdout, subkey) { - t.Fatalf("subkey found in %s", stdout) - } - - expectedStderr := "./non-existing-key.gpg does not exists" - if !strings.Contains(stderr, expectedStderr) { - t.Fatalf("'%v' not in '%v'", expectedStderr, stdout) - } -} diff --git a/pkgs/sops-pgp-hook/sops-pgp-hook.bash b/pkgs/sops-pgp-hook/sops-pgp-hook.bash deleted file mode 100644 index e0ced80f..00000000 --- a/pkgs/sops-pgp-hook/sops-pgp-hook.bash +++ /dev/null @@ -1,32 +0,0 @@ -_sopsAddKey() { - @gpg@ --quiet --import "$key" - local fpr - # only add the first fingerprint, this way we ignore subkeys - fpr=$(@gpg@ --with-fingerprint --with-colons --show-key "$key" \ - | awk -F: '$1 == "fpr" { print $10; exit }') - if [[ $fpr != "" ]]; then - export SOPS_PGP_FP=''${SOPS_PGP_FP-}''${SOPS_PGP_FP:+','}$fpr - fi -} - -sopsPGPHook() { - local key dir - for key in ${sopsPGPKeys-}; do - if [[ -f "$key" ]]; then - _sopsAddKey "$key" - else - echo "$key does not exists" >&2 - fi - done - for dir in ${sopsPGPKeyDirs-}; do - while IFS= read -r -d '' key; do - _sopsAddKey "$key" - done < <(find -L "$dir" -type f \( -name '*.gpg' -o -name '*.asc' \) -print0) - done -} - -if [ -z "${shellHook-}" ]; then - shellHook=sopsPGPHook -else - shellHook="sopsPGPHook;${shellHook}" -fi diff --git a/pkgs/sops-pgp-hook/test-assets/existing-key.gpg b/pkgs/sops-pgp-hook/test-assets/existing-key.gpg deleted file mode 100644 index eba37387..00000000 Binary files a/pkgs/sops-pgp-hook/test-assets/existing-key.gpg and /dev/null differ diff --git a/pkgs/sops-pgp-hook/test-assets/keys/key-with-subkeys.asc b/pkgs/sops-pgp-hook/test-assets/keys/key-with-subkeys.asc deleted file mode 100644 index 71f54050..00000000 --- a/pkgs/sops-pgp-hook/test-assets/keys/key-with-subkeys.asc +++ /dev/null @@ -1,61 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQENBF8YRjUBCACfdPLn/dUxr3SHZR2p6+aFgnu0jFA1KESBAgqA5TzDNIjaecff -MV2nP7Z+vmcyRq2oJb7zAd2UfavjH0jPzRJi+TP6NvJepfMj8SaflKEh8kZN6Gv0 -Zl0Fr6WtTPuenATuesAYvFDW+b2ZYRIs/XzEI+HP96XaW4MCWgTPwMPP8gMPZO3c -Cv+A5T9p1RHZjezfHktA0z+3F07IDquIT9K5d5Iapy0illnV7TziCdN6EbPUQZis -FqAP1kxgWUzJvYLswIncGb9WAw8T49GMVUtP8hoBiw3g0mNfnvzJUTBjYQr/e5X2 -+ZnGM4qqdrMTdTHFdQtzKHlsh3S1EI9Z5qB9ABEBAAG0H0pvaG4gRG9lIDxqb2hu -LmRvZUB0aGFsaGVpbS5pbz6JAU4EEwEIADgWIQTjt0ZPvon1N47UvGD8kltC/It3 -PQUCXxhGNQIbAQULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD8kltC/It3PTqF -B/9fbQmuDb0mg+rt8ALndJUXkiUK3osGTcmPhBXWPZpViCRsP4nOmBsM0yv5aA2y -Gsei+dHfLXK48UDkUFo/bt2ACEywCE+7QFBrhCnQFKS5sbPpE6EcqKF3eWzfR0I/ -PnzXQNA/igryuvaPxvQN9lIdY/Gzfi/erhv+f4/PgR53TzIhXYw2f2rwD4dCoiH3 -QkmKez3tasTc8zq7nwhlZ0d1pnbFn0qlCJCntrQT6caCkcWh9IiutrK0ozxfoa9H -Yqt/FdTWuRgEG1vj+/0RG2pggqE9D2LSkX6+gW0vai2OzTCn1a8VlrX2uYmDnXVF -b/bQBlAFW6wyGC6HhH+xckmHuQENBF8YRk0BCADCB2ov5gXA6X388bBeJ7YwWTMr -YuSAe2PZzZ3GipuQ4PRIpFvSLXHx4G4NT60J0G48cFL8M6dZCyJbCe+dZPyCEYLl -3V+5txpN0dYcbUTiG07uEAyDbuhkuda9goSJlfvJF8vUxGPNNHbYWPOO3hLsGQse -aQVGHSqu8WlRCWSDtNEyc11cOlty/zhEv3M5ZtBrJTahfy0u5RrCzk/x9SRea+MV -0xhYd1cKfi5ud/mNpQnnrbLuD+Gy9YgcqJUyxi6zvdfoCDYR4Sv7Rf0fxafxDkNZ -GQlqmPkaEuw21eedczmwUqMC57ZJz3avgDxKcLZG8uFC+6DY4thTSERPRb85ABEB -AAGJAmwEGAEIACAWIQTjt0ZPvon1N47UvGD8kltC/It3PQUCXxhGTQIbAgFACRD8 -kltC/It3PcB0IAQZAQgAHRYhBJTxdPWICQSU5z0INaebFoC8TZpUBQJfGEZNAAoJ -EKebFoC8TZpUWpQH/3de056tFqVIvsFjkYUW3oGylexVQEXeQljoqYx7NWsSxNX6 -NMEwYYJdNWgwXhL4CD8Tn0/3sVx/mMUDtbgQnQ8rKMB3lXZ3U6yzGghh5RdSmhAk -EQGhiYkZhIONce46i7rk+AE+hGi57p1IqsZ0UketOKoWN7rVYXbVLPf78cphD7G+ -Q7v7KWJYx8i3VkXDHJXP3wRlhbkbqVJAyUTmi63c7femOB+mDPJMBHBFmw6Opxt4 -AZR+qYczOLAyJCGA2MBx2U/26mVztkMYl5rJ80VKgUe/CEb8kD/uaOBYXeokGfqh -i6TV9fQxYokkmSU/4SIa+F+VcTu0xfRC46+EosL2Pwf+NpMRgpWihbF9EEh6RqX4 -NUxN4IVV/6frG19AJD8XNq0E8+bXvKVhHEy/Ea68ILKaJb/SIpcFY0aIJ3tHC0b2 -mh97nm5FdyRXRUNXoQ/u2wsOcD+HGK3P/jdrJDkNETuLTNr4Uff5Nn1Y6XydKviK -i7UwexDtX+wmyr1JxRdu7AJhdSi3rWY2lQxMMem7+9xyyqZ8uY2SixroMjcV/DL/ -7AjvfucWL6e/pESpvTp29sAKM5PUtMWqjm/vgapiFVLhXIEYWqe6OowXQ+smlkah -zQ00HJxLILNy3Mu2Vic543OVbLNRoWlJYQ1/zAqMxU5GLmdZA1hwncQT/3UCZ5zI -L7kBDQRfGEZvAQgAoPiXUlpQFLISXSHobzPtUwx1O3x+hN7XH57+VV0Hktz94+gb -NMj+3UBd67NZeseqUG6PMQ1ztEAuht7UX/LjLlmcBwmTD7iFeT8Y+hlo1+7AeKE6 -a3RGycTMOm5HFra1n3KcQqkmh6RMlTPxcpvb5wXHJXIiWvoW/k7C3nbFbJlzVZtK -dW2x4tcU/INsk2qgpir4Ou2nCwAXOOb91E/SDR+isPj4lYOp69AZa266YvShX1/X -UObG5UXSsPGs7CbZC9i+DcgJFhGjicrjgoEbAhPBmAdUwWaFiMls2WXmIkq9utv+ -uxQmQixEXL+/OQgXPJGzCmGaq4h/2JC9nCf5swARAQABiQE2BBgBCAAgFiEE47dG -T76J9TeO1Lxg/JJbQvyLdz0FAl8YRm8CGwwACgkQ/JJbQvyLdz01cAf9EsfZye6j -p7GuxInoZaJBblWW3tbJjOOH3GdeOhcY8ygImsRDcYFRIsp9QLp91eCRxGsT/EMz -q0vgQk4zsZOyTXMcK4TUMgUtsRY6zmiHSRez7sw0CA919KY/PAbMfB5F0qkuR5FL -auoAeYOUY1oYpiE7AG5rdtNNI1PC+EUeiivs+raczH3kLJr71fwjFD6Jnh9FDgPZ -QsYaWIe6t0quho6cNaL8DYfXtdJZh2vKgWX8h/qu5dUB/aHx18rWTvcQ7zmQ/ADn -oweTR94hbSL9O9mm3LoWogr/vtUGWvs8LlIYjFDUXj4TRx2svclcBdKI0qrjrCDx -Ed+ons5QiTE1LLkBDQRfGEaGAQgArDpYiwBV9Xml93knxoGVFi+rj0YL35gdVraT -ZqbeN+s0t9QPshzVpZz0jyqZSxFE/ojUmO7WMrH/Jb8nLVGvm/fq/jLEMfnbpJnb -Cu6ym7ed1QP7Y2JDMYJorlcS8BQCOSGSe2QRRD6h0nvgygrg70XKnkIhH6YfGCLt -pC96WWdbEr78d/dMloPRIW1Tsp58bXVkTfIseXpdCB5zVGj58GBtelWibvIms+/T -SRzw7QU9uiPjcrl5iZ8UMcRlE4mdMEBhlZ+eZaKgRdDNNDpcsd38xtktA52hs3uY -AgFKUGQ+PxY9cG9haVyCwwYwCVKo24/hTreTL1DydFLmAxaonQARAQABiQE2BBgB -CAAgFiEE47dGT76J9TeO1Lxg/JJbQvyLdz0FAl8YRoYCGyAACgkQ/JJbQvyLdz1d -gggAj+Gcxy6irGlkX9mxoq+sZv9WzRjXRT8xkB8H10tzqqOLQ0uzXeob07vDi3MC -6dBahE8sJq4ByOruy4hNhKUa/vtBm/G4ijTDNFzS/fmafDxZ+FObUDz6gLHGVbf0 -/NpwOmfcc/UeDCgI5t3TRcbQ9PugwCfw7A7eCYS34NspS549WJfzdNj8FcNBzsbi -yx1/wnXb7Eq5+kvZaPR1vodAW7YptYrUQCbCbioFGwq+zd1SHPXMS2h2D0ncMNbP -+C/y/AXliH+P08WRJ6kazSkSHv93UNM2nOt6x04vlk652WejLDc0t3wWNQEp0Q4U -W1YR5NNzw2GqjhH3nhj/SnUwXg== -=jshU ------END PGP PUBLIC KEY BLOCK----- diff --git a/pkgs/sops-pgp-hook/test-assets/keys/key.asc b/pkgs/sops-pgp-hook/test-assets/keys/key.asc deleted file mode 120000 index 34bc2403..00000000 --- a/pkgs/sops-pgp-hook/test-assets/keys/key.asc +++ /dev/null @@ -1 +0,0 @@ -../../../sops-install-secrets/test-assets/key.asc \ No newline at end of file diff --git a/pkgs/sops-pgp-hook/test-assets/keys/key.gpg b/pkgs/sops-pgp-hook/test-assets/keys/key.gpg deleted file mode 100644 index c168d740..00000000 Binary files a/pkgs/sops-pgp-hook/test-assets/keys/key.gpg and /dev/null differ diff --git a/pkgs/sops-pgp-hook/test-assets/shell.nix b/pkgs/sops-pgp-hook/test-assets/shell.nix deleted file mode 100644 index 71173fde..00000000 --- a/pkgs/sops-pgp-hook/test-assets/shell.nix +++ /dev/null @@ -1,14 +0,0 @@ -# shell.nix -with import { }; -mkShell { - sopsPGPKeyDirs = [ - "./keys" - ]; - sopsPGPKeys = [ - "./existing-key.gpg" - "./non-existing-key.gpg" - ]; - nativeBuildInputs = [ - (pkgs.callPackage ../../.. { }).sops-pgp-hook - ]; -} diff --git a/pkgs/unit-tests.nix b/pkgs/unit-tests.nix index f3d3678b..5126da9b 100644 --- a/pkgs/unit-tests.nix +++ b/pkgs/unit-tests.nix @@ -1,21 +1,18 @@ { - pkgs ? import { }, + stdenv, + gnupg, + util-linux, + nix, + sops-install-secrets, }: -let - sopsPkgs = import ../. { inherit pkgs; }; -in -pkgs.stdenv.mkDerivation { - name = "env"; - nativeBuildInputs = - with pkgs; - [ - bashInteractive - gnupg - util-linux - nix - sopsPkgs.sops-pgp-hook-test - ] - ++ pkgs.lib.optional (pkgs.stdenv.isLinux) sopsPkgs.sops-install-secrets.unittest; +stdenv.mkDerivation { + name = "unittests"; + nativeBuildInputs = [ + gnupg + util-linux + nix + sops-install-secrets.unittest + ]; # allow to prefetch shell dependencies in build phase dontUnpack = true; installPhase = '' @@ -23,11 +20,7 @@ pkgs.stdenv.mkDerivation { ''; shellHook = '' set -x - NIX_PATH=nixpkgs=${toString pkgs.path} TEST_ASSETS=$(realpath ./pkgs/sops-pgp-hook/test-assets) \ - sops-pgp-hook.test - ${pkgs.lib.optionalString (pkgs.stdenv.isLinux) '' - sudo TEST_ASSETS=$(realpath ./pkgs/sops-install-secrets/test-assets) \ - unshare --mount --fork sops-install-secrets.test - ''} + sudo TEST_ASSETS=$(realpath ./pkgs/sops-install-secrets/test-assets) \ + unshare --mount --fork sops-install-secrets.test ''; } diff --git a/scripts/update-vendor-hash.sh b/scripts/update-vendor-hash.sh index 4cd02a75..bda4a08c 100755 --- a/scripts/update-vendor-hash.sh +++ b/scripts/update-vendor-hash.sh @@ -1,5 +1,6 @@ #!/usr/bin/env nix-shell #!nix-shell -i bash -p nix -p coreutils -p gnused -p gawk +# shellcheck shell=bash set -exuo pipefail