├── .github ├── release-drafter.yml └── workflows │ ├── ci.yaml │ ├── doc.yml │ ├── flakehub-publish-tagged.yml │ └── release-drafter.yml ├── .gitignore ├── LICENSE ├── README.md ├── contrib ├── _incr_version └── release ├── default.nix ├── doc ├── acknowledgements.md ├── community-and-support.md ├── contributing.md ├── features.md ├── install-via-fetchtarball.md ├── install-via-flakes.md ├── install-via-niv.md ├── install-via-nix-channel.md ├── introduction.md ├── notices.md ├── overriding-age-binary.md ├── problem-and-solution.md ├── reference.md ├── rekeying.md ├── threat-model-warnings.md ├── toc.md └── tutorial.md ├── example ├── -leading-hyphen-filename.age ├── passwordfile-user1.age ├── secret1.age ├── secret2.age └── secrets.nix ├── example_keys ├── system1 ├── system1.pub ├── user1 └── user1.pub ├── flake.lock ├── flake.nix ├── modules ├── age-home.nix └── age.nix ├── overlay.nix ├── pkgs ├── agenix.nix ├── agenix.sh └── doc.nix └── test ├── install_ssh_host_keys.nix ├── install_ssh_host_keys_darwin.nix ├── integration.nix ├── integration_darwin.nix └── integration_hm_darwin.nix /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | 2 | name-template: '$RESOLVED_VERSION' 3 | tag-template: '$RESOLVED_VERSION' 4 | categories: 5 | - title: '🚀 Features' 6 | labels: 7 | - 'feature' 8 | - 'enhancement' 9 | - title: '🐛 Bug Fixes' 10 | labels: 11 | - 'fix' 12 | - 'bugfix' 13 | - 'bug' 14 | - title: '🧰 Development' 15 | label: 'dev' 16 | - title: '🤖 Dependencies' 17 | label: 'dependencies' 18 | - title: '🔒 Security' 19 | label: 'security' 20 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 21 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 22 | version-resolver: 23 | major: 24 | labels: 25 | - 'major' 26 | minor: 27 | labels: 28 | - 'minor' 29 | patch: 30 | labels: 31 | - 'patch' 32 | default: patch 33 | template: | 34 | ## Changes 35 | $CHANGES 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | pull_request: 4 | push: 5 | jobs: 6 | tests-linux: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: cachix/install-nix-action@v22 11 | with: 12 | extra_nix_config: | 13 | system-features = nixos-test recursive-nix benchmark big-parallel kvm 14 | extra-experimental-features = recursive-nix nix-command flakes 15 | - run: nix build 16 | - run: nix build .#doc 17 | - run: nix fmt . -- --check 18 | - run: nix flake check 19 | tests-darwin: 20 | runs-on: macos-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: cachix/install-nix-action@v30 24 | with: 25 | extra_nix_config: | 26 | system-features = nixos-test recursive-nix benchmark big-parallel kvm 27 | extra-experimental-features = recursive-nix nix-command flakes 28 | - run: nix build 29 | - run: nix build .#doc 30 | - run: nix fmt . -- --check 31 | - run: nix flake check 32 | - name: "Install nix-darwin module" 33 | run: | 34 | # Determine architecture of GitHub runner 35 | ARCH=x86_64 36 | if [ "$(arch)" = arm64 ]; then 37 | ARCH=aarch64 38 | fi 39 | # https://github.com/ryantm/agenix/pull/230#issuecomment-1867025385 40 | 41 | sudo mv /etc/nix/nix.conf{,.bak} 42 | nix \ 43 | --extra-experimental-features 'nix-command flakes' \ 44 | build .#checks."${ARCH}"-darwin.integration 45 | 46 | ./result/activate-user 47 | sudo ./result/activate 48 | - name: "Test nix-darwin module" 49 | run: | 50 | sudo /run/current-system/sw/bin/agenix-integration 51 | - name: "Test home-manager module" 52 | run: | 53 | # Do the job of `home-manager switch` in-line to avoid rate limiting 54 | nix build .#homeConfigurations.integration-darwin.activationPackage 55 | ./result/activate 56 | ~/agenix-home-integration/bin/agenix-home-integration 57 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: [$default-branch] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Setup Pages 34 | uses: actions/configure-pages@v3 35 | - uses: cachix/install-nix-action@v20 36 | - run: nix build .#doc && mkdir -p _site/ && cp -r ./result/multi/* _site/ 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v1 39 | - name: Deploy to GitHub Pages 40 | id: deployment 41 | uses: actions/deploy-pages@v1 42 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-tagged.yml: -------------------------------------------------------------------------------- 1 | name: "Publish tags to FlakeHub" 2 | on: 3 | push: 4 | tags: 5 | - "v?[0-9]+.[0-9]+.[0-9]+*" 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: "The existing tag to publish to FlakeHub" 10 | type: "string" 11 | required: true 12 | jobs: 13 | flakehub-publish: 14 | runs-on: "ubuntu-latest" 15 | permissions: 16 | id-token: "write" 17 | contents: "read" 18 | steps: 19 | - uses: "actions/checkout@v3" 20 | with: 21 | ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" 22 | - uses: "DeterminateSystems/nix-installer-action@main" 23 | - uses: "DeterminateSystems/flakehub-push@main" 24 | with: 25 | visibility: "public" 26 | name: "ryantm/agenix" 27 | tag: "${{ inputs.tag }}" 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | # pull_request event is required only for autolabeler 9 | pull_request: 10 | # Only following types are handled by the action, but one can default to all as well 11 | types: [opened, reopened, synchronize] 12 | # pull_request_target event is required for autolabeler to support PRs from forks 13 | pull_request_target: 14 | types: [opened, reopened, synchronize] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | update_release_draft: 21 | permissions: 22 | # write permission is required to create a github release 23 | contents: write 24 | # write permission is required for autolabeler 25 | # otherwise, read permission is required at least 26 | pull-requests: write 27 | runs-on: ubuntu-latest 28 | steps: 29 | # Drafts your next Release notes as Pull Requests are merged into "main" 30 | - uses: release-drafter/release-drafter@v5 31 | continue-on-error: true 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /result 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # agenix - [age](https://github.com/FiloSottile/age)-encrypted secrets for NixOS 2 | 3 | `agenix` is a small and convenient Nix library for securely managing and deploying secrets using common public-private SSH key pairs: 4 | You can encrypt a secret (password, access-token, etc.) on a source machine using a number of public SSH keys, 5 | and deploy that encrypted secret to any another target machine that has the corresponding private SSH key of one of those public keys. 6 | This project contains two parts: 7 | 1. An `agenix` commandline app (CLI) to encrypt secrets into secured `.age` files that can be copied into the Nix store. 8 | 2. An `agenix` NixOS module to conveniently 9 | * add those encrypted secrets (`.age` files) into the Nix store so that they can be deployed like any other Nix package using `nixos-rebuild` or similar tools. 10 | * automatically decrypt on a target machine using the private SSH keys on that machine 11 | * automatically mount these decrypted secrets on a well known path like `/run/agenix/...` to be consumed. 12 | 13 | ## Contents 14 | 15 | * [Problem and solution](#problem-and-solution) 16 | * [Features](#features) 17 | * [Installation](#installation) 18 | * [niv](#install-via-niv) 19 | * [nix-channel](#install-via-nix-channel) 20 | * [fetchTarball](#install-via-fetchtarball) 21 | * [flakes](#install-via-flakes) 22 | * [Tutorial](#tutorial) 23 | * [Reference](#reference) 24 | * [`age` module reference](#age-module-reference) 25 | * [`age-home` module reference](#age-home-module-reference) 26 | * [agenix CLI reference](#agenix-cli-reference) 27 | * [Community and Support](#community-and-support) 28 | * [Threat model/Warnings](#threat-modelwarnings) 29 | * [Contributing](#contributing) 30 | * [Acknowledgements](#acknowledgements) 31 | 32 | ## Problem and solution 33 | 34 | All files in the Nix store are readable by any system user, so it is not a suitable place for including cleartext secrets. Many existing tools (like NixOps deployment.keys) deploy secrets separately from `nixos-rebuild`, making deployment, caching, and auditing more difficult. Out-of-band secret management is also less reproducible. 35 | 36 | `agenix` solves these issues by using your pre-existing SSH key infrastructure and `age` to encrypt secrets into the Nix store. Secrets are decrypted using an SSH host private key during NixOS system activation. 37 | 38 | ## Features 39 | 40 | * Secrets are encrypted with SSH keys 41 | * system public keys via `ssh-keyscan` 42 | * can use public keys available on GitHub for users (for example, https://github.com/ryantm.keys) 43 | * No GPG 44 | * Very little code, so it should be easy for you to audit 45 | * Encrypted secrets are stored in the Nix store, so a separate distribution mechanism is not necessary 46 | 47 | ## Notices 48 | 49 | * Password-protected ssh keys: since age does not support ssh-agent, password-protected ssh keys do not work well. For example, if you need to rekey 20 secrets you will have to enter your password 20 times. 50 | 51 | ## Installation 52 | 53 |
54 | 55 | 56 | ### Install via [niv](https://github.com/nmattia/niv) 57 | 58 | 59 | 60 | First add it to niv: 61 | 62 | ```ShellSession 63 | $ niv add ryantm/agenix 64 | ``` 65 | 66 | #### Install module via niv 67 | 68 | Then add the following to your `configuration.nix` in the `imports` list: 69 | 70 | ```nix 71 | { 72 | imports = [ "${(import ./nix/sources.nix).agenix}/modules/age.nix" ]; 73 | } 74 | ``` 75 | 76 | #### Install home-manager module via niv 77 | 78 | Add the following to your home configuration: 79 | 80 | ```nix 81 | { 82 | imports = [ "${(import ./nix/sources.nix).agenix}/modules/age-home.nix" ]; 83 | } 84 | ``` 85 | 86 | #### Install CLI via niv 87 | 88 | To install the `agenix` binary: 89 | 90 | ```nix 91 | { 92 | environment.systemPackages = [ (pkgs.callPackage "${(import ./nix/sources.nix).agenix}/pkgs/agenix.nix" {}) ]; 93 | } 94 | ``` 95 | 96 |
97 | 98 |
99 | 100 | 101 | ### Install via nix-channel 102 | 103 | 104 | 105 | As root run: 106 | 107 | ```ShellSession 108 | $ sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix 109 | $ sudo nix-channel --update 110 | ``` 111 | 112 | #### Install module via nix-channel 113 | 114 | Then add the following to your `configuration.nix` in the `imports` list: 115 | 116 | ```nix 117 | { 118 | imports = [ ]; 119 | } 120 | ``` 121 | 122 | #### Install home-manager module via nix-channel 123 | 124 | Add the following to your home configuration: 125 | 126 | ```nix 127 | { 128 | imports = [ ]; 129 | } 130 | ``` 131 | 132 | #### Install CLI via nix-channel 133 | 134 | To install the `agenix` binary: 135 | 136 | ```nix 137 | { 138 | environment.systemPackages = [ (pkgs.callPackage {}) ]; 139 | } 140 | ``` 141 | 142 |
143 | 144 |
145 | 146 | 147 | ### Install via fetchTarball 148 | 149 | 150 | 151 | #### Install module via fetchTarball 152 | 153 | Add the following to your configuration.nix: 154 | 155 | ```nix 156 | { 157 | imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix" ]; 158 | } 159 | ``` 160 | 161 | or with pinning: 162 | 163 | ```nix 164 | { 165 | imports = let 166 | # replace this with an actual commit id or tag 167 | commit = "298b235f664f925b433614dc33380f0662adfc3f"; 168 | in [ 169 | "${builtins.fetchTarball { 170 | url = "https://github.com/ryantm/agenix/archive/${commit}.tar.gz"; 171 | # update hash from nix build output 172 | sha256 = ""; 173 | }}/modules/age.nix" 174 | ]; 175 | } 176 | ``` 177 | 178 | #### Install home-manager module via fetchTarball 179 | 180 | Add the following to your home configuration: 181 | 182 | ```nix 183 | { 184 | imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age-home.nix" ]; 185 | } 186 | ``` 187 | 188 | Or with pinning: 189 | 190 | ```nix 191 | { 192 | imports = let 193 | # replace this with an actual commit id or tag 194 | commit = "298b235f664f925b433614dc33380f0662adfc3f"; 195 | in [ 196 | "${builtins.fetchTarball { 197 | url = "https://github.com/ryantm/agenix/archive/${commit}.tar.gz"; 198 | # update hash from nix build output 199 | sha256 = ""; 200 | }}/modules/age-home.nix" 201 | ]; 202 | } 203 | ``` 204 | 205 | #### Install CLI via fetchTarball 206 | 207 | To install the `agenix` binary: 208 | 209 | ```nix 210 | { 211 | environment.systemPackages = [ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {}) ]; 212 | } 213 | ``` 214 | 215 |
216 | 217 |
218 | 219 | 220 | ### Install via Flakes 221 | 222 | 223 | 224 | #### Install module via Flakes 225 | 226 | ```nix 227 | { 228 | inputs.agenix.url = "github:ryantm/agenix"; 229 | # optional, not necessary for the module 230 | #inputs.agenix.inputs.nixpkgs.follows = "nixpkgs"; 231 | # optionally choose not to download darwin deps (saves some resources on Linux) 232 | #inputs.agenix.inputs.darwin.follows = ""; 233 | 234 | outputs = { self, nixpkgs, agenix }: { 235 | # change `yourhostname` to your actual hostname 236 | nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem { 237 | # change to your system: 238 | system = "x86_64-linux"; 239 | modules = [ 240 | ./configuration.nix 241 | agenix.nixosModules.default 242 | ]; 243 | }; 244 | }; 245 | } 246 | ``` 247 | 248 | #### Install home-manager module via Flakes 249 | 250 | ```nix 251 | { 252 | inputs.agenix.url = "github:ryantm/agenix"; 253 | 254 | outputs = { self, nixpkgs, agenix, home-manager }: { 255 | homeConfigurations."username" = home-manager.lib.homeManagerConfiguration { 256 | # ... 257 | modules = [ 258 | agenix.homeManagerModules.default 259 | # ... 260 | ]; 261 | }; 262 | }; 263 | } 264 | ``` 265 | 266 | #### Install CLI via Flakes 267 | 268 | You can run the CLI tool ad-hoc without installing it: 269 | 270 | ```ShellSession 271 | nix run github:ryantm/agenix -- --help 272 | ``` 273 | 274 | But you can also add it permanently into a [NixOS module](https://wiki.nixos.org/wiki/NixOS_modules) 275 | (replace system "x86_64-linux" with your system): 276 | 277 | ```nix 278 | { 279 | environment.systemPackages = [ agenix.packages.x86_64-linux.default ]; 280 | } 281 | ``` 282 | 283 | e.g. inside your `flake.nix` file: 284 | 285 | ```nix 286 | { 287 | inputs.agenix.url = "github:ryantm/agenix"; 288 | # ... 289 | 290 | outputs = { self, nixpkgs, agenix }: { 291 | # change `yourhostname` to your actual hostname 292 | nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem { 293 | system = "x86_64-linux"; 294 | modules = [ 295 | # ... 296 | { 297 | environment.systemPackages = [ agenix.packages.${system}.default ]; 298 | } 299 | ]; 300 | }; 301 | }; 302 | } 303 | ``` 304 | 305 |
306 | 307 | ## Tutorial 308 | 309 | 1. The system you want to deploy secrets to should already exist and 310 | have `sshd` running on it so that it has generated SSH host keys in 311 | `/etc/ssh/`. 312 | 313 | 2. Make a directory to store secrets and `secrets.nix` file for listing secrets and their public keys: 314 | ```ShellSession 315 | $ mkdir secrets 316 | $ cd secrets 317 | $ touch secrets.nix 318 | ``` 319 | This `secrets.nix` file is **not** imported into your NixOS configuration. 320 | It's only used for the `agenix` CLI tool (example below) to know which public keys to use for encryption. 321 | 3. Add public keys to your `secrets.nix` file: 322 | ```nix 323 | let 324 | user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH"; 325 | user2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI6jSq53F/3hEmSs+oq9L4TwOo1PrDMAgcA1uo1CCV/"; 326 | users = [ user1 user2 ]; 327 | 328 | system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE"; 329 | system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1"; 330 | systems = [ system1 system2 ]; 331 | in 332 | { 333 | "secret1.age".publicKeys = [ user1 system1 ]; 334 | "secret2.age".publicKeys = users ++ systems; 335 | } 336 | ``` 337 | These are the users and systems that will be able to decrypt the `.age` files later with their corresponding private keys. 338 | You can obtain the public keys from 339 | * your local computer usually in `~/.ssh`, e.g. `~/.ssh/id_ed25519.pub`. 340 | * from a running target machine with `ssh-keyscan`: 341 | ```ShellSession 342 | $ ssh-keyscan 343 | ... ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1 344 | ... 345 | ``` 346 | * from GitHub like https://github.com/ryantm.keys. 347 | 4. Create a secret file: 348 | ```ShellSession 349 | $ agenix -e secret1.age 350 | ``` 351 | It will open a temporary file in the app configured in your $EDITOR environment variable. 352 | When you save that file its content will be encrypted with all the public keys mentioned in the `secrets.nix` file. 353 | 5. Add secret to a NixOS module config: 354 | ```nix 355 | { 356 | age.secrets.secret1.file = ../secrets/secret1.age; 357 | } 358 | ``` 359 | When the `age.secrets` attribute set contains a secret, the `agenix` NixOS module will later automatically decrypt and mount that secret under the default path `/run/agenix/secret1`. 360 | Here the `secret1.age` file becomes part of your NixOS deployment, i.e. moves into the Nix store. 361 | 362 | 6. Reference the secrets' mount path in your config: 363 | ```nix 364 | { 365 | users.users.user1 = { 366 | isNormalUser = true; 367 | passwordFile = config.age.secrets.secret1.path; 368 | }; 369 | } 370 | ``` 371 | You can reference the mount path to the (later) unencrypted secret already in your other configuration. 372 | So `config.age.secrets.secret1.path` will contain the path `/run/agenix/secret1` by default. 373 | 7. Use `nixos-rebuild` or [another deployment tool](https://wiki.nixos.org/wiki/Applications#Deployment") of choice as usual. 374 | 375 | The `secret1.age` file will be copied over to the target machine like any other Nix package. 376 | Then it will be decrypted and mounted as described before. 377 | 8. Edit secret files: 378 | ```ShellSession 379 | $ agenix -e secret1.age 380 | ``` 381 | It assumes your SSH private key is in `~/.ssh/`. 382 | In order to decrypt and open a `.age` file for editing you need the private key of one of the public keys 383 | it was encrypted with. You can pass the private key you want to use explicitly with `-i`, e.g. 384 | ```ShellSession 385 | $ agenix -e secret1.age -i ~/.ssh/id_ed25519 386 | ``` 387 | 388 | ### Using agenix with home-manager 389 | 390 | The home-manager module follows the same general principles as the NixOS module but is scoped to a single user. Here's how to use it: 391 | 392 | 1. Add the home-manager module to your configuration as shown in the Installation section. 393 | 2. Define your SSH identities and secrets: 394 | 395 | ```nix 396 | { 397 | age = { 398 | identityPaths = [ "~/.ssh/id_ed25519" ]; 399 | secrets = { 400 | example-secret = { 401 | file = ../secrets/example-secret.age; 402 | }; 403 | }; 404 | }; 405 | } 406 | ``` 407 | 408 | 3. Reference your secrets in your home configuration: 409 | 410 | ```nix 411 | { 412 | programs.some-program = { 413 | enable = true; 414 | passwordFile = config.age.secrets.example-secret.path; 415 | }; 416 | } 417 | ``` 418 | 419 | When you run `home-manager switch`, your secrets will be decrypted to a user-specific directory (usually `$XDG_RUNTIME_DIR/agenix` on Linux or a temporary directory on Darwin) and can be referenced in your configuration. 420 | 421 | ## Reference 422 | 423 | ### `age` module reference 424 | 425 | #### `age.secrets` 426 | 427 | `age.secrets` attrset of secrets. You always need to use this 428 | configuration option. Defaults to `{}`. 429 | 430 | #### `age.secrets..file` 431 | 432 | `age.secrets..file` is the path to the encrypted `.age` for this 433 | secret. This is the only required secret option. 434 | 435 | Example: 436 | 437 | ```nix 438 | { 439 | age.secrets.monitrc.file = ../secrets/monitrc.age; 440 | } 441 | ``` 442 | 443 | #### `age.secrets..path` 444 | 445 | `age.secrets..path` is the path where the secret is decrypted 446 | to. Defaults to `/run/agenix/` (`config.age.secretsDir/`). 447 | 448 | Example defining a different path: 449 | 450 | ```nix 451 | { 452 | age.secrets.monitrc = { 453 | file = ../secrets/monitrc.age; 454 | path = "/etc/monitrc"; 455 | }; 456 | } 457 | ``` 458 | 459 | For many services, you do not need to set this. Instead, refer to the 460 | decryption path in your configuration with 461 | `config.age.secrets..path`. 462 | 463 | Example referring to path: 464 | 465 | ```nix 466 | { 467 | users.users.ryantm = { 468 | isNormalUser = true; 469 | passwordFile = config.age.secrets.passwordfile-ryantm.path; 470 | }; 471 | } 472 | ``` 473 | 474 | ##### builtins.readFile anti-pattern 475 | 476 | ```nix 477 | { 478 | # Do not do this! 479 | config.password = builtins.readFile config.age.secrets.secret1.path; 480 | } 481 | ``` 482 | 483 | This can cause the cleartext to be placed into the world-readable Nix 484 | store. Instead, have your services read the cleartext path at runtime. 485 | 486 | #### `age.secrets..mode` 487 | 488 | `age.secrets..mode` is permissions mode of the decrypted secret 489 | in a format understood by chmod. Usually, you only need to use this in 490 | combination with `age.secrets..owner` and 491 | `age.secrets..group` 492 | 493 | Example: 494 | 495 | ```nix 496 | { 497 | age.secrets.nginx-htpasswd = { 498 | file = ../secrets/nginx.htpasswd.age; 499 | mode = "770"; 500 | owner = "nginx"; 501 | group = "nginx"; 502 | }; 503 | } 504 | ``` 505 | 506 | #### `age.secrets..owner` 507 | 508 | `age.secrets..owner` is the username of the decrypted file's 509 | owner. Usually, you only need to use this in combination with 510 | `age.secrets..mode` and `age.secrets..group` 511 | 512 | Example: 513 | 514 | ```nix 515 | { 516 | age.secrets.nginx-htpasswd = { 517 | file = ../secrets/nginx.htpasswd.age; 518 | mode = "770"; 519 | owner = "nginx"; 520 | group = "nginx"; 521 | }; 522 | } 523 | ``` 524 | 525 | #### `age.secrets..group` 526 | 527 | `age.secrets..group` is the name of the decrypted file's 528 | group. Usually, you only need to use this in combination with 529 | `age.secrets..owner` and `age.secrets..mode` 530 | 531 | Example: 532 | 533 | ```nix 534 | { 535 | age.secrets.nginx-htpasswd = { 536 | file = ../secrets/nginx.htpasswd.age; 537 | mode = "770"; 538 | owner = "nginx"; 539 | group = "nginx"; 540 | }; 541 | } 542 | ``` 543 | 544 | #### `age.secrets..symlink` 545 | 546 | `age.secrets..symlink` is a boolean. If true (the default), 547 | secrets are symlinked to `age.secrets..path`. If false, secrets 548 | are copied to `age.secrets..path`. Usually, you want to keep 549 | this as true, because it secure cleanup of secrets no longer 550 | used. (The symlink will still be there, but it will be broken.) If 551 | false, you are responsible for cleaning up your own secrets after you 552 | stop using them. 553 | 554 | Some programs do not like following symlinks (for example Java 555 | programs like Elasticsearch). 556 | 557 | Example: 558 | 559 | ```nix 560 | { 561 | age.secrets."elasticsearch.conf" = { 562 | file = ../secrets/elasticsearch.conf.age; 563 | symlink = false; 564 | }; 565 | } 566 | ``` 567 | 568 | #### `age.secrets..name` 569 | 570 | `age.secrets..name` is the string of the name of the file after 571 | it is decrypted. Defaults to the `` in the attrpath, but can be 572 | set separately if you want the file name to be different from the 573 | attribute name part. 574 | 575 | Example of a secret with a name different from its attrpath: 576 | 577 | ```nix 578 | { 579 | age.secrets.monit = { 580 | name = "monitrc"; 581 | file = ../secrets/monitrc.age; 582 | }; 583 | } 584 | ``` 585 | 586 | #### `age.ageBin` 587 | 588 | `age.ageBin` the string of the path to the `age` binary. Usually, you 589 | don't need to change this. Defaults to `age/bin/age`. 590 | 591 | Overriding `age.ageBin` example: 592 | 593 | ```nix 594 | {pkgs, ...}:{ 595 | age.ageBin = "${pkgs.age}/bin/age"; 596 | } 597 | ``` 598 | 599 | #### `age.identityPaths` 600 | 601 | `age.identityPaths` is a list of paths to recipient keys to try to use to 602 | decrypt the secrets. By default, it is the `rsa` and `ed25519` keys in 603 | `config.services.openssh.hostKeys`, and on NixOS you usually don't need to 604 | change this. The list items should be strings (`"/path/to/id_rsa"`), not 605 | nix paths (`../path/to/id_rsa`), as the latter would copy your private key to 606 | the nix store, which is the exact situation `agenix` is designed to avoid. At 607 | least one of the file paths must be present at runtime and able to decrypt the 608 | secret in question. Overriding `age.identityPaths` example: 609 | 610 | ```nix 611 | { 612 | age.identityPaths = [ "/var/lib/persistent/ssh_host_ed25519_key" ]; 613 | } 614 | ``` 615 | 616 | #### `age.secretsDir` 617 | 618 | `age.secretsDir` is the directory where secrets are symlinked to by 619 | default. Usually, you don't need to change this. Defaults to 620 | `/run/agenix`. 621 | 622 | Overriding `age.secretsDir` example: 623 | 624 | ```nix 625 | { 626 | age.secretsDir = "/run/keys"; 627 | } 628 | ``` 629 | 630 | #### `age.secretsMountPoint` 631 | 632 | `age.secretsMountPoint` is the directory where the secret generations 633 | are created before they are symlinked. Usually, you don't need to 634 | change this. Defaults to `/run/agenix.d`. 635 | 636 | 637 | Overriding `age.secretsMountPoint` example: 638 | 639 | ```nix 640 | { 641 | age.secretsMountPoint = "/run/secret-generations"; 642 | } 643 | ``` 644 | 645 | ### `age-home` module reference 646 | 647 | The home-manager module provides options similar to the NixOS module but scoped to a single user. 648 | 649 | #### `age.secrets` 650 | 651 | `age.secrets` attrset of secrets. You always need to use this 652 | configuration option. Defaults to `{}`. 653 | 654 | #### `age.secrets..file` 655 | 656 | `age.secrets..file` is the path to the encrypted `.age` for this 657 | secret. This is the only required secret option. 658 | 659 | #### `age.secrets..path` 660 | 661 | `age.secrets..path` is the path where the secret is decrypted 662 | to. Defaults to `$XDG_RUNTIME_DIR/agenix/` on Linux and 663 | `$(getconf DARWIN_USER_TEMP_DIR)/agenix/` on Darwin. 664 | 665 | #### `age.secrets..mode` 666 | 667 | `age.secrets..mode` is permissions mode of the decrypted secret 668 | in a format understood by chmod. 669 | 670 | #### `age.secrets..symlink` 671 | 672 | `age.secrets..symlink` is a boolean. If true (the default), 673 | secrets are symlinked to `age.secrets..path`. If false, secrets 674 | are copied to `age.secrets..path`. 675 | 676 | #### `age.identityPaths` 677 | 678 | `age.identityPaths` is a list of paths to SSH private keys to use for decryption. 679 | This is a required option; there is no default value. 680 | 681 | #### `age.secretsDir` 682 | 683 | `age.secretsDir` is the directory where secrets are symlinked to by 684 | default. Defaults to `$XDG_RUNTIME_DIR/agenix` on Linux and 685 | `$(getconf DARWIN_USER_TEMP_DIR)/agenix` on Darwin. 686 | 687 | #### `age.secretsMountPoint` 688 | 689 | `age.secretsMountPoint` is the directory where the secret generations 690 | are created before they are symlinked. Defaults to `$XDG_RUNTIME_DIR/agenix.d` 691 | on Linux and `$(getconf DARWIN_USER_TEMP_DIR)/agenix.d` on Darwin. 692 | 693 | ### agenix CLI reference 694 | 695 | ``` 696 | agenix - edit and rekey age secret files 697 | 698 | agenix -e FILE [-i PRIVATE_KEY] 699 | agenix -r [-i PRIVATE_KEY] 700 | 701 | options: 702 | -h, --help show help 703 | -e, --edit FILE edits FILE using $EDITOR 704 | -r, --rekey re-encrypts all secrets with specified recipients 705 | -d, --decrypt FILE decrypts FILE to STDOUT 706 | -i, --identity identity to use when decrypting 707 | -v, --verbose verbose output 708 | 709 | FILE an age-encrypted file 710 | 711 | PRIVATE_KEY a path to a private SSH key used to decrypt file 712 | 713 | EDITOR environment variable of editor to use when editing FILE 714 | 715 | If STDIN is not interactive, EDITOR will be set to "cp /dev/stdin" 716 | 717 | RULES environment variable with path to Nix file specifying recipient public keys. 718 | Defaults to './secrets.nix' 719 | ``` 720 | 721 | #### Rekeying 722 | 723 | If you change the public keys in `secrets.nix`, you should rekey your 724 | secrets: 725 | 726 | ```ShellSession 727 | $ agenix --rekey 728 | ``` 729 | 730 | To rekey a secret, you have to be able to decrypt it. Because of 731 | randomness in `age`'s encryption algorithms, the files always change 732 | when rekeyed, even if the identities do not. (This eventually could be 733 | improved upon by reading the identities from the age file.) 734 | 735 | #### Overriding age binary 736 | 737 | The agenix CLI uses `age` by default as its age implemenation, you 738 | can use the `rage` implementation with Flakes like this: 739 | 740 | ```nix 741 | {pkgs,agenix,...}:{ 742 | environment.systemPackages = [ 743 | (agenix.packages.x86_64-linux.default.override { ageBin = "${pkgs.rage}/bin/rage"; }) 744 | ]; 745 | } 746 | ``` 747 | 748 | ## Community and Support 749 | 750 | Support and development discussion is available here on GitHub and 751 | also through [Matrix](https://matrix.to/#/#agenix:nixos.org). 752 | 753 | ## Threat model/Warnings 754 | 755 | This project has not been audited by a security professional. 756 | 757 | People unfamiliar with `age` might be surprised that secrets are not 758 | authenticated. This means that every attacker that has write access to 759 | the secret files can modify secrets because public keys are exposed. 760 | This seems like not a problem on the first glance because changing the 761 | configuration itself could expose secrets easily. However, reviewing 762 | configuration changes is easier than reviewing random secrets (for 763 | example, 4096-bit rsa keys). This would be solved by having a message 764 | authentication code (MAC) like other implementations like GPG or 765 | [sops](https://github.com/Mic92/sops-nix) have, however this was left 766 | out for simplicity in `age`. 767 | 768 | Additionally you should only encrypt secrets that you are able to make useless in the event that they are decrypted in the future and be ready to rotate them periodically as [age](https://github.com/FiloSottile/age) is [as of 19th June 2024 NOT Post-Quantum Safe](https://github.com/FiloSottile/age/discussions/231#discussioncomment-3092773) and so in case the threat actor can access your encrypted keys e.g. via their use in a public repository then they can utilize the strategy of [Harvest Now, Decrypt Later](https://en.wikipedia.org/wiki/Harvest_now,_decrypt_later) to store your keys now for later decryption including the case where a major vulnerability is found that would expose the secrets. See https://github.com/FiloSottile/age/issues/578 for details. 769 | 770 | ## Contributing 771 | 772 | * The main branch is protected against direct pushes 773 | * All changes must go through GitHub PR review and get at least one approval 774 | * PR titles and commit messages should be prefixed with at least one of these categories: 775 | * contrib - things that make the project development better 776 | * doc - documentation 777 | * feature - new features 778 | * fix - bug fixes 779 | * Please update or make integration tests for new features 780 | * Use `nix fmt` to format nix code 781 | 782 | 783 | ### Tests 784 | 785 | You can run the tests with 786 | 787 | ```ShellSession 788 | nix flake check 789 | ``` 790 | 791 | You can run the integration tests in interactive mode like this: 792 | 793 | ```ShellSession 794 | nix run .#checks.x86_64-linux.integration.driverInteractive 795 | ``` 796 | 797 | After it starts, enter `run_tests()` to run the tests. 798 | 799 | ## Acknowledgements 800 | 801 | This project is based off of [sops-nix](https://github.com/Mic92/sops-nix) created Mic92. Thank you to Mic92 for inspiration and advice. 802 | -------------------------------------------------------------------------------- /contrib/_incr_version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | grep -q "$1" pkgs/agenix.nix || (echo "Couldn't find version $1 in pkgs/agenix.nix" && exit 1) 5 | sed -i "s/$1/$2/g" pkgs/agenix.nix 6 | git add pkgs/agenix.nix 7 | git commit -m "version $2" 8 | exit 0 9 | -------------------------------------------------------------------------------- /contrib/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-shell 2 | #! nix-shell -p python3 -i python 3 | 4 | # based off of https://git.sr.ht/~sircmpwn/dotfiles/tree/master/item/bin/semver 5 | 6 | import os 7 | import subprocess 8 | import sys 9 | import tempfile 10 | 11 | if subprocess.run(["git", "branch", "--show-current"], stdout=subprocess.PIPE 12 | ).stdout.decode().strip() != "main": 13 | print("WARNING! Not on the main branch.") 14 | 15 | subprocess.run(["git", "pull", "--rebase"]) 16 | p = subprocess.run(["git", "describe", "--abbrev=0"], stdout=subprocess.PIPE) 17 | describe = p.stdout.decode().strip() 18 | old_version = describe.split("-")[0].split(".") 19 | if len(old_version) == 2: 20 | [major, minor] = old_version 21 | [major, minor] = map(int, [major, minor]) 22 | patch = 0 23 | else: 24 | [major, minor, patch] = old_version 25 | [major, minor, patch] = map(int, [major, minor, patch]) 26 | 27 | p = subprocess.run(["git", "shortlog", "--no-merges", f"{describe}..HEAD"], 28 | stdout=subprocess.PIPE) 29 | shortlog = p.stdout.decode() 30 | 31 | new_version = None 32 | 33 | if sys.argv[1] == "patch": 34 | patch += 1 35 | elif sys.argv[1] == "minor": 36 | minor += 1 37 | patch = 0 38 | elif sys.argv[1] == "major": 39 | major += 1 40 | minor = patch = 0 41 | else: 42 | new_version = sys.argv[1] 43 | 44 | if new_version is None: 45 | if len(old_version) == 2 and patch == 0: 46 | new_version = f"{major}.{minor}" 47 | else: 48 | new_version = f"{major}.{minor}.{patch}" 49 | 50 | p = None 51 | if os.path.exists("contrib/_incr_version"): 52 | p = subprocess.run(["contrib/_incr_version", describe, new_version]) 53 | else: 54 | print("Warning: no _incr_version script. " + 55 | "Does this project have any specific release requirements?") 56 | 57 | if p and p.returncode != 0: 58 | print("Error: _incr_version returned nonzero exit code") 59 | sys.exit(1) 60 | 61 | with tempfile.NamedTemporaryFile() as f: 62 | basename = os.path.basename(os.getcwd()) 63 | f.write(f"{basename} {new_version}\n\n".encode()) 64 | f.write(shortlog.encode()) 65 | f.flush() 66 | subprocess.run(["git", "tag", "-e", "-F", f.name, "-a", new_version]) 67 | print(new_version) 68 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: { 2 | agenix = pkgs.callPackage ./pkgs/agenix.nix {}; 3 | } 4 | -------------------------------------------------------------------------------- /doc/acknowledgements.md: -------------------------------------------------------------------------------- 1 | # Acknowledgements {#acknowledgements} 2 | 3 | This project is based off of [sops-nix](https://github.com/Mic92/sops-nix) created Mic92. Thank you to Mic92 for inspiration and advice. 4 | -------------------------------------------------------------------------------- /doc/community-and-support.md: -------------------------------------------------------------------------------- 1 | # Community and Support {#community-and-support} 2 | 3 | Support and development discussion is available here on GitHub and 4 | also through [Matrix](https://matrix.to/#/#agenix:nixos.org). 5 | -------------------------------------------------------------------------------- /doc/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing {#contributing} 2 | 3 | * The main branch is protected against direct pushes 4 | * All changes must go through GitHub PR review and get at least one approval 5 | * PR titles and commit messages should be prefixed with at least one of these categories: 6 | * contrib - things that make the project development better 7 | * doc - documentation 8 | * feature - new features 9 | * fix - bug fixes 10 | * Please update or make integration tests for new features 11 | * Use `nix fmt` to format nix code 12 | 13 | 14 | ## Tests 15 | 16 | You can run the tests with 17 | 18 | ```ShellSession 19 | nix flake check 20 | ``` 21 | 22 | You can run the integration tests in interactive mode like this: 23 | 24 | ```ShellSession 25 | nix run .#checks.x86_64-linux.integration.driverInteractive 26 | ``` 27 | 28 | After it starts, enter `run_tests()` to run the tests. 29 | -------------------------------------------------------------------------------- /doc/features.md: -------------------------------------------------------------------------------- 1 | # Features {#features} 2 | 3 | * Secrets are encrypted with SSH keys 4 | * system public keys via `ssh-keyscan` 5 | * can use public keys available on GitHub for users (for example, https://github.com/ryantm.keys) 6 | * No GPG 7 | * Very little code, so it should be easy for you to audit 8 | * Encrypted secrets are stored in the Nix store, so a separate distribution mechanism is not necessary 9 | -------------------------------------------------------------------------------- /doc/install-via-fetchtarball.md: -------------------------------------------------------------------------------- 1 | # Install via fetchTarball {#install-via-fetchtarball} 2 | 3 | #### Install module via fetchTarball 4 | 5 | Add the following to your configuration.nix: 6 | 7 | ```nix 8 | { 9 | imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix" ]; 10 | } 11 | ``` 12 | 13 | or with pinning: 14 | 15 | ```nix 16 | { 17 | imports = let 18 | # replace this with an actual commit id or tag 19 | commit = "298b235f664f925b433614dc33380f0662adfc3f"; 20 | in [ 21 | "${builtins.fetchTarball { 22 | url = "https://github.com/ryantm/agenix/archive/${commit}.tar.gz"; 23 | # update hash from nix build output 24 | sha256 = ""; 25 | }}/modules/age.nix" 26 | ]; 27 | } 28 | ``` 29 | 30 | #### Install CLI via fetchTarball 31 | 32 | To install the `agenix` binary: 33 | 34 | ```nix 35 | { 36 | environment.systemPackages = [ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {}) ]; 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /doc/install-via-flakes.md: -------------------------------------------------------------------------------- 1 | # Install via Flakes {#install-via-flakes} 2 | 3 | ## Install module via Flakes 4 | 5 | ```nix 6 | { 7 | inputs.agenix.url = "github:ryantm/agenix"; 8 | # optional, not necessary for the module 9 | #inputs.agenix.inputs.nixpkgs.follows = "nixpkgs"; 10 | 11 | outputs = { self, nixpkgs, agenix }: { 12 | # change `yourhostname` to your actual hostname 13 | nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem { 14 | # change to your system: 15 | system = "x86_64-linux"; 16 | modules = [ 17 | ./configuration.nix 18 | agenix.nixosModules.default 19 | ]; 20 | }; 21 | }; 22 | } 23 | ``` 24 | 25 | ## Install CLI via Flakes 26 | 27 | You don't need to install it, 28 | 29 | ```ShellSession 30 | nix run github:ryantm/agenix -- --help 31 | ``` 32 | 33 | but, if you want to (change the system based on your system): 34 | 35 | ```nix 36 | { 37 | environment.systemPackages = [ agenix.packages.x86_64-linux.default ]; 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /doc/install-via-niv.md: -------------------------------------------------------------------------------- 1 | # Install via [niv](https://github.com/nmattia/niv) {#install-via-niv} 2 | 3 | First add it to niv: 4 | 5 | ```ShellSession 6 | $ niv add ryantm/agenix 7 | ``` 8 | 9 | ## Install module via niv 10 | 11 | Then add the following to your `configuration.nix` in the `imports` list: 12 | 13 | ```nix 14 | { 15 | imports = [ "${(import ./nix/sources.nix).agenix}/modules/age.nix" ]; 16 | } 17 | ``` 18 | 19 | ## Install CLI via niv 20 | 21 | To install the `agenix` binary: 22 | 23 | ```nix 24 | { 25 | environment.systemPackages = [ (pkgs.callPackage "${(import ./nix/sources.nix).agenix}/pkgs/agenix.nix" {}) ]; 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /doc/install-via-nix-channel.md: -------------------------------------------------------------------------------- 1 | # Install via nix-channel {#install-via-nix-channel} 2 | 3 | As root run: 4 | 5 | ```ShellSession 6 | $ sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix 7 | $ sudo nix-channel --update 8 | ``` 9 | 10 | ## Install module via nix-channel 11 | 12 | Then add the following to your `configuration.nix` in the `imports` list: 13 | 14 | ```nix 15 | { 16 | imports = [ ]; 17 | } 18 | ``` 19 | 20 | ## Install CLI via nix-channel 21 | 22 | To install the `agenix` binary: 23 | 24 | ```nix 25 | { 26 | environment.systemPackages = [ (pkgs.callPackage {}) ]; 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /doc/introduction.md: -------------------------------------------------------------------------------- 1 | # agenix - [age](https://github.com/FiloSottile/age)-encrypted secrets for NixOS {#introduction} 2 | 3 | `agenix` is a commandline tool for managing secrets encrypted with your existing SSH keys. This project also includes the NixOS module `age` for adding encrypted secrets into the Nix store and decrypting them. 4 | -------------------------------------------------------------------------------- /doc/notices.md: -------------------------------------------------------------------------------- 1 | # Notices {#notices} 2 | 3 | * Password-protected ssh keys: since age does not support ssh-agent, password-protected ssh keys do not work well. For example, if you need to rekey 20 secrets you will have to enter your password 20 times. 4 | -------------------------------------------------------------------------------- /doc/overriding-age-binary.md: -------------------------------------------------------------------------------- 1 | # Overriding age binary {#overriding-age-binary} 2 | 3 | The agenix CLI uses `age` by default as its age implemenation, you 4 | can use the `rage` implementation with Flakes like this: 5 | 6 | ```nix 7 | {pkgs,agenix,...}:{ 8 | environment.systemPackages = [ 9 | (agenix.packages.x86_64-linux.default.override { ageBin = "${pkgs.rage}/bin/rage"; }) 10 | ]; 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /doc/problem-and-solution.md: -------------------------------------------------------------------------------- 1 | # Problem and solution {#problem-and-solution} 2 | 3 | All files in the Nix store are readable by any system user, so it is not a suitable place for including cleartext secrets. Many existing tools (like NixOps deployment.keys) deploy secrets separately from `nixos-rebuild`, making deployment, caching, and auditing more difficult. Out-of-band secret management is also less reproducible. 4 | 5 | `agenix` solves these issues by using your pre-existing SSH key infrastructure and `age` to encrypt secrets into the Nix store. Secrets are decrypted using an SSH host private key during NixOS system activation. 6 | -------------------------------------------------------------------------------- /doc/reference.md: -------------------------------------------------------------------------------- 1 | # Reference {#reference} 2 | 3 | ## `age` module reference {#age-module-reference} 4 | 5 | ### `age.secrets` 6 | 7 | `age.secrets` attrset of secrets. You always need to use this 8 | configuration option. Defaults to `{}`. 9 | 10 | ### `age.secrets..file` 11 | 12 | `age.secrets..file` is the path to the encrypted `.age` for this 13 | secret. This is the only required secret option. 14 | 15 | Example: 16 | 17 | ```nix 18 | { 19 | age.secrets.monitrc.file = ../secrets/monitrc.age; 20 | } 21 | ``` 22 | 23 | ### `age.secrets..path` 24 | 25 | `age.secrets..path` is the path where the secret is decrypted 26 | to. Defaults to `/run/agenix/` (`config.age.secretsDir/`). 27 | 28 | Example defining a different path: 29 | 30 | ```nix 31 | { 32 | age.secrets.monitrc = { 33 | file = ../secrets/monitrc.age; 34 | path = "/etc/monitrc"; 35 | }; 36 | } 37 | ``` 38 | 39 | For many services, you do not need to set this. Instead, refer to the 40 | decryption path in your configuration with 41 | `config.age.secrets..path`. 42 | 43 | Example referring to path: 44 | 45 | ```nix 46 | { 47 | users.users.ryantm = { 48 | isNormalUser = true; 49 | passwordFile = config.age.secrets.passwordfile-ryantm.path; 50 | }; 51 | } 52 | ``` 53 | 54 | #### builtins.readFile anti-pattern 55 | 56 | ```nix 57 | { 58 | # Do not do this! 59 | config.password = builtins.readFile config.age.secrets.secret1.path; 60 | } 61 | ``` 62 | 63 | This can cause the cleartext to be placed into the world-readable Nix 64 | store. Instead, have your services read the cleartext path at runtime. 65 | 66 | ### `age.secrets..mode` 67 | 68 | `age.secrets..mode` is permissions mode of the decrypted secret 69 | in a format understood by chmod. Usually, you only need to use this in 70 | combination with `age.secrets..owner` and 71 | `age.secrets..group` 72 | 73 | Example: 74 | 75 | ```nix 76 | { 77 | age.secrets.nginx-htpasswd = { 78 | file = ../secrets/nginx.htpasswd.age; 79 | mode = "770"; 80 | owner = "nginx"; 81 | group = "nginx"; 82 | }; 83 | } 84 | ``` 85 | 86 | ### `age.secrets..owner` 87 | 88 | `age.secrets..owner` is the username of the decrypted file's 89 | owner. Usually, you only need to use this in combination with 90 | `age.secrets..mode` and `age.secrets..group` 91 | 92 | Example: 93 | 94 | ```nix 95 | { 96 | age.secrets.nginx-htpasswd = { 97 | file = ../secrets/nginx.htpasswd.age; 98 | mode = "770"; 99 | owner = "nginx"; 100 | group = "nginx"; 101 | }; 102 | } 103 | ``` 104 | 105 | ### `age.secrets..group` 106 | 107 | `age.secrets..group` is the name of the decrypted file's 108 | group. Usually, you only need to use this in combination with 109 | `age.secrets..owner` and `age.secrets..mode` 110 | 111 | Example: 112 | 113 | ```nix 114 | { 115 | age.secrets.nginx-htpasswd = { 116 | file = ../secrets/nginx.htpasswd.age; 117 | mode = "770"; 118 | owner = "nginx"; 119 | group = "nginx"; 120 | }; 121 | } 122 | ``` 123 | 124 | ### `age.secrets..symlink` 125 | 126 | `age.secrets..symlink` is a boolean. If true (the default), 127 | secrets are symlinked to `age.secrets..path`. If false, secerts 128 | are copied to `age.secrets..path`. Usually, you want to keep 129 | this as true, because it secure cleanup of secrets no longer 130 | used. (The symlink will still be there, but it will be broken.) If 131 | false, you are responsible for cleaning up your own secrets after you 132 | stop using them. 133 | 134 | Some programs do not like following symlinks (for example Java 135 | programs like Elasticsearch). 136 | 137 | Example: 138 | 139 | ```nix 140 | { 141 | age.secrets."elasticsearch.conf" = { 142 | file = ../secrets/elasticsearch.conf.age; 143 | symlink = false; 144 | }; 145 | } 146 | ``` 147 | 148 | ### `age.secrets..name` 149 | 150 | `age.secrets..name` is the string of the name of the file after 151 | it is decrypted. Defaults to the `` in the attrpath, but can be 152 | set separately if you want the file name to be different from the 153 | attribute name part. 154 | 155 | Example of a secret with a name different from its attrpath: 156 | 157 | ```nix 158 | { 159 | age.secrets.monit = { 160 | name = "monitrc"; 161 | file = ../secrets/monitrc.age; 162 | }; 163 | } 164 | ``` 165 | 166 | ### `age.ageBin` 167 | 168 | `age.ageBin` the string of the path to the `age` binary. Usually, you 169 | don't need to change this. Defaults to `age/bin/age`. 170 | 171 | Overriding `age.ageBin` example: 172 | 173 | ```nix 174 | {pkgs, ...}:{ 175 | age.ageBin = "${pkgs.age}/bin/age"; 176 | } 177 | ``` 178 | 179 | ### `age.identityPaths` 180 | 181 | `age.identityPaths` is a list of paths to recipient keys to try to use to 182 | decrypt the secrets. By default, it is the `rsa` and `ed25519` keys in 183 | `config.services.openssh.hostKeys`, and on NixOS you usually don't need to 184 | change this. The list items should be strings (`"/path/to/id_rsa"`), not 185 | nix paths (`../path/to/id_rsa`), as the latter would copy your private key to 186 | the nix store, which is the exact situation `agenix` is designed to avoid. At 187 | least one of the file paths must be present at runtime and able to decrypt the 188 | secret in question. Overriding `age.identityPaths` example: 189 | 190 | ```nix 191 | { 192 | age.identityPaths = [ "/var/lib/persistent/ssh_host_ed25519_key" ]; 193 | } 194 | ``` 195 | 196 | ### `age.secretsDir` 197 | 198 | `age.secretsDir` is the directory where secrets are symlinked to by 199 | default.Usually, you don't need to change this. Defaults to 200 | `/run/agenix`. 201 | 202 | Overriding `age.secretsDir` example: 203 | 204 | ```nix 205 | { 206 | age.secretsDir = "/run/keys"; 207 | } 208 | ``` 209 | 210 | ### `age.secretsMountPoint` 211 | 212 | `age.secretsMountPoint` is the directory where the secret generations 213 | are created before they are symlinked. Usually, you don't need to 214 | change this. Defaults to `/run/agenix.d`. 215 | 216 | 217 | Overriding `age.secretsMountPoint` example: 218 | 219 | ```nix 220 | { 221 | age.secretsMountPoint = "/run/secret-generations"; 222 | } 223 | ``` 224 | 225 | ## agenix CLI reference {#agenix-cli-reference} 226 | 227 | ``` 228 | agenix - edit and rekey age secret files 229 | 230 | agenix -e FILE [-i PRIVATE_KEY] 231 | agenix -r [-i PRIVATE_KEY] 232 | 233 | options: 234 | -h, --help show help 235 | -e, --edit FILE edits FILE using $EDITOR 236 | -r, --rekey re-encrypts all secrets with specified recipients 237 | -d, --decrypt FILE decrypts FILE to STDOUT 238 | -i, --identity identity to use when decrypting 239 | -v, --verbose verbose output 240 | 241 | FILE an age-encrypted file 242 | 243 | PRIVATE_KEY a path to a private SSH key used to decrypt file 244 | 245 | EDITOR environment variable of editor to use when editing FILE 246 | 247 | If STDIN is not interactive, EDITOR will be set to "cp /dev/stdin" 248 | 249 | RULES environment variable with path to Nix file specifying recipient public keys. 250 | Defaults to './secrets.nix' 251 | -------------------------------------------------------------------------------- /doc/rekeying.md: -------------------------------------------------------------------------------- 1 | # Rekeying {#rekeying} 2 | 3 | If you change the public keys in `secrets.nix`, you should rekey your 4 | secrets: 5 | 6 | ```ShellSession 7 | $ agenix --rekey 8 | ``` 9 | 10 | To rekey a secret, you have to be able to decrypt it. Because of 11 | randomness in `age`'s encryption algorithms, the files always change 12 | when rekeyed, even if the identities do not. (This eventually could be 13 | improved upon by reading the identities from the age file.) 14 | -------------------------------------------------------------------------------- /doc/threat-model-warnings.md: -------------------------------------------------------------------------------- 1 | # Threat model/Warnings {#threat-model-warnings} 2 | 3 | This project has not been audited by a security professional. 4 | 5 | People unfamiliar with `age` might be surprised that secrets are not 6 | authenticated. This means that every attacker that has write access to 7 | the secret files can modify secrets because public keys are exposed. 8 | This seems like not a problem on the first glance because changing the 9 | configuration itself could expose secrets easily. However, reviewing 10 | configuration changes is easier than reviewing random secrets (for 11 | example, 4096-bit rsa keys). This would be solved by having a message 12 | authentication code (MAC) like other implementations like GPG or 13 | [sops](https://github.com/Mic92/sops-nix) have, however this was left 14 | out for simplicity in `age`. 15 | -------------------------------------------------------------------------------- /doc/toc.md: -------------------------------------------------------------------------------- 1 | # agenix 2 | 3 | * [Introduction](#introduction) 4 | * [Problem and solution](#problem-and-solution) 5 | * [Features](#features) 6 | * Installation 7 | * [flakes](#install-via-flakes) 8 | * [niv](#install-via-niv) 9 | * [fetchTarball](#install-via-fetchtarball) 10 | * [nix-channel](#install-via-nix-channel) 11 | * [Tutorial](#tutorial) 12 | * [Reference](#reference) 13 | * [`age` module reference](#age-module-reference) 14 | * [agenix CLI reference](#agenix-cli-reference) 15 | * [Community and Support](#community-and-support) 16 | * [Threat model/Warnings](#threat-model-warnings) 17 | * [Contributing](#contributing) 18 | * [Acknowledgements](#acknowledgements) 19 | -------------------------------------------------------------------------------- /doc/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial {#tutorial} 2 | 3 | 1. The system you want to deploy secrets to should already exist and 4 | have `sshd` running on it so that it has generated SSH host keys in 5 | `/etc/ssh/`. 6 | 7 | 2. Make a directory to store secrets and `secrets.nix` file for listing secrets and their public keys (This file is **not** imported into your NixOS configuration. It is only used for the `agenix` CLI.): 8 | 9 | ```ShellSession 10 | $ mkdir secrets 11 | $ cd secrets 12 | $ touch secrets.nix 13 | ``` 14 | 3. Add public keys to `secrets.nix` file (hint: use `ssh-keyscan` or GitHub (for example, https://github.com/ryantm.keys)): 15 | ```nix 16 | let 17 | user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH"; 18 | user2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI6jSq53F/3hEmSs+oq9L4TwOo1PrDMAgcA1uo1CCV/"; 19 | users = [ user1 user2 ]; 20 | 21 | system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE"; 22 | system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1"; 23 | systems = [ system1 system2 ]; 24 | in 25 | { 26 | "secret1.age".publicKeys = [ user1 system1 ]; 27 | "secret2.age".publicKeys = users ++ systems; 28 | } 29 | ``` 30 | 4. Edit secret files (these instructions assume your SSH private key is in ~/.ssh/): 31 | ```ShellSession 32 | $ agenix -e secret1.age 33 | ``` 34 | 5. Add secret to a NixOS module config: 35 | ```nix 36 | { 37 | age.secrets.secret1.file = ../secrets/secret1.age; 38 | } 39 | ``` 40 | 6. Use the secret in your config: 41 | ```nix 42 | { 43 | users.users.user1 = { 44 | isNormalUser = true; 45 | passwordFile = config.age.secrets.secret1.path; 46 | }; 47 | } 48 | ``` 49 | 7. NixOS rebuild or use your deployment tool like usual. 50 | 51 | The secret will be decrypted to the value of `config.age.secrets.secret1.path` (`/run/agenix/secret1` by default). 52 | -------------------------------------------------------------------------------- /example/-leading-hyphen-filename.age: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryantm/agenix/4835b1dc898959d8547a871ef484930675cb47f1/example/-leading-hyphen-filename.age -------------------------------------------------------------------------------- /example/passwordfile-user1.age: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryantm/agenix/4835b1dc898959d8547a871ef484930675cb47f1/example/passwordfile-user1.age -------------------------------------------------------------------------------- /example/secret1.age: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryantm/agenix/4835b1dc898959d8547a871ef484930675cb47f1/example/secret1.age -------------------------------------------------------------------------------- /example/secret2.age: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryantm/agenix/4835b1dc898959d8547a871ef484930675cb47f1/example/secret2.age -------------------------------------------------------------------------------- /example/secrets.nix: -------------------------------------------------------------------------------- 1 | let 2 | user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH"; 3 | system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE"; 4 | in { 5 | "secret1.age".publicKeys = [user1 system1]; 6 | "secret2.age".publicKeys = [user1]; 7 | "passwordfile-user1.age".publicKeys = [user1 system1]; 8 | "-leading-hyphen-filename.age".publicKeys = [user1 system1]; 9 | } 10 | -------------------------------------------------------------------------------- /example_keys/system1: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDyQ8iK/xUs9XCXXKFuvUfja1s8Biv/t4Caag9bfC9sxAAAAJA3yvCWN8rw 4 | lgAAAAtzc2gtZWQyNTUxOQAAACDyQ8iK/xUs9XCXXKFuvUfja1s8Biv/t4Caag9bfC9sxA 5 | AAAEA+J2V6AG1NriAIvnNKRauIEh1JE9HSdhvKJ68a5Fm0w/JDyIr/FSz1cJdcoW69R+Nr 6 | WzwGK/+3gJpqD1t8L2zEAAAADHJ5YW50bUBob21lMQE= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /example_keys/system1.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE 2 | -------------------------------------------------------------------------------- /example_keys/user1: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACC9InTb4BornFoLqf5j+/M8gtt7hY2KtHr3FnYxkFGgRwAAAJC2JJ8htiSf 4 | IQAAAAtzc2gtZWQyNTUxOQAAACC9InTb4BornFoLqf5j+/M8gtt7hY2KtHr3FnYxkFGgRw 5 | AAAEDxt5gC/s53IxiKAjfZJVCCcFIsdeERdIgbYhLO719+Kb0idNvgGiucWgup/mP78zyC 6 | 23uFjYq0evcWdjGQUaBHAAAADHJ5YW50bUBob21lMQE= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /example_keys/user1.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH 2 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "darwin": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1744478979, 11 | "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", 12 | "owner": "lnl7", 13 | "repo": "nix-darwin", 14 | "rev": "43975d782b418ebf4969e9ccba82466728c2851b", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "lnl7", 19 | "ref": "master", 20 | "repo": "nix-darwin", 21 | "type": "github" 22 | } 23 | }, 24 | "home-manager": { 25 | "inputs": { 26 | "nixpkgs": [ 27 | "nixpkgs" 28 | ] 29 | }, 30 | "locked": { 31 | "lastModified": 1745494811, 32 | "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", 33 | "owner": "nix-community", 34 | "repo": "home-manager", 35 | "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", 36 | "type": "github" 37 | }, 38 | "original": { 39 | "owner": "nix-community", 40 | "repo": "home-manager", 41 | "type": "github" 42 | } 43 | }, 44 | "nixpkgs": { 45 | "locked": { 46 | "lastModified": 1745391562, 47 | "narHash": "sha256-sPwcCYuiEopaafePqlG826tBhctuJsLx/mhKKM5Fmjo=", 48 | "owner": "NixOS", 49 | "repo": "nixpkgs", 50 | "rev": "8a2f738d9d1f1d986b5a4cd2fd2061a7127237d7", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "NixOS", 55 | "ref": "nixos-unstable", 56 | "repo": "nixpkgs", 57 | "type": "github" 58 | } 59 | }, 60 | "root": { 61 | "inputs": { 62 | "darwin": "darwin", 63 | "home-manager": "home-manager", 64 | "nixpkgs": "nixpkgs", 65 | "systems": "systems" 66 | } 67 | }, 68 | "systems": { 69 | "locked": { 70 | "lastModified": 1681028828, 71 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 72 | "owner": "nix-systems", 73 | "repo": "default", 74 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "nix-systems", 79 | "repo": "default", 80 | "type": "github" 81 | } 82 | } 83 | }, 84 | "root": "root", 85 | "version": 7 86 | } 87 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Secret management with age"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | darwin = { 7 | url = "github:lnl7/nix-darwin/master"; 8 | inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | home-manager = { 11 | url = "github:nix-community/home-manager"; 12 | inputs.nixpkgs.follows = "nixpkgs"; 13 | }; 14 | systems.url = "github:nix-systems/default"; 15 | }; 16 | 17 | outputs = { 18 | self, 19 | nixpkgs, 20 | darwin, 21 | home-manager, 22 | systems, 23 | }: let 24 | eachSystem = nixpkgs.lib.genAttrs (import systems); 25 | in { 26 | nixosModules.age = ./modules/age.nix; 27 | nixosModules.default = self.nixosModules.age; 28 | 29 | darwinModules.age = ./modules/age.nix; 30 | darwinModules.default = self.darwinModules.age; 31 | 32 | homeManagerModules.age = ./modules/age-home.nix; 33 | homeManagerModules.default = self.homeManagerModules.age; 34 | 35 | overlays.default = import ./overlay.nix; 36 | 37 | formatter = eachSystem (system: nixpkgs.legacyPackages.${system}.alejandra); 38 | 39 | packages = eachSystem (system: { 40 | agenix = nixpkgs.legacyPackages.${system}.callPackage ./pkgs/agenix.nix {}; 41 | doc = nixpkgs.legacyPackages.${system}.callPackage ./pkgs/doc.nix {inherit self;}; 42 | default = self.packages.${system}.agenix; 43 | }); 44 | 45 | checks = 46 | nixpkgs.lib.genAttrs ["aarch64-darwin" "x86_64-darwin"] (system: { 47 | integration = 48 | (darwin.lib.darwinSystem { 49 | inherit system; 50 | modules = [ 51 | ./test/integration_darwin.nix 52 | 53 | # Allow new-style nix commands in CI 54 | {nix.extraOptions = "experimental-features = nix-command flakes";} 55 | 56 | home-manager.darwinModules.home-manager 57 | { 58 | home-manager = { 59 | verbose = true; 60 | useGlobalPkgs = true; 61 | useUserPackages = true; 62 | backupFileExtension = "hmbak"; 63 | users.runner = ./test/integration_hm_darwin.nix; 64 | }; 65 | } 66 | ]; 67 | }) 68 | .system; 69 | }) 70 | // { 71 | x86_64-linux.integration = import ./test/integration.nix { 72 | inherit nixpkgs home-manager; 73 | pkgs = nixpkgs.legacyPackages.x86_64-linux; 74 | system = "x86_64-linux"; 75 | }; 76 | }; 77 | 78 | darwinConfigurations.integration-x86_64.system = self.checks.x86_64-darwin.integration; 79 | darwinConfigurations.integration-aarch64.system = self.checks.aarch64-darwin.integration; 80 | 81 | # Work-around for https://github.com/nix-community/home-manager/issues/3075 82 | legacyPackages = nixpkgs.lib.genAttrs ["aarch64-darwin" "x86_64-darwin"] (system: { 83 | homeConfigurations.integration-darwin = home-manager.lib.homeManagerConfiguration { 84 | pkgs = nixpkgs.legacyPackages.${system}; 85 | modules = [./test/integration_hm_darwin.nix]; 86 | }; 87 | }); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /modules/age-home.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | options, 4 | lib, 5 | pkgs, 6 | ... 7 | }: 8 | with lib; let 9 | cfg = config.age; 10 | 11 | ageBin = lib.getExe config.age.package; 12 | 13 | newGeneration = '' 14 | _agenix_generation="$(basename "$(readlink "${cfg.secretsDir}")" || echo 0)" 15 | (( ++_agenix_generation )) 16 | echo "[agenix] creating new generation in ${cfg.secretsMountPoint}/$_agenix_generation" 17 | mkdir -p "${cfg.secretsMountPoint}" 18 | chmod 0751 "${cfg.secretsMountPoint}" 19 | mkdir -p "${cfg.secretsMountPoint}/$_agenix_generation" 20 | chmod 0751 "${cfg.secretsMountPoint}/$_agenix_generation" 21 | ''; 22 | 23 | setTruePath = secretType: '' 24 | ${ 25 | if secretType.symlink 26 | then '' 27 | _truePath="${cfg.secretsMountPoint}/$_agenix_generation/${secretType.name}" 28 | '' 29 | else '' 30 | _truePath="${secretType.path}" 31 | '' 32 | } 33 | ''; 34 | 35 | installSecret = secretType: '' 36 | ${setTruePath secretType} 37 | echo "decrypting '${secretType.file}' to '$_truePath'..." 38 | TMP_FILE="$_truePath.tmp" 39 | 40 | IDENTITIES=() 41 | # shellcheck disable=2043 42 | for identity in ${toString cfg.identityPaths}; do 43 | test -r "$identity" || continue 44 | IDENTITIES+=(-i) 45 | IDENTITIES+=("$identity") 46 | done 47 | 48 | test "''${#IDENTITIES[@]}" -eq 0 && echo "[agenix] WARNING: no readable identities found!" 49 | 50 | mkdir -p "$(dirname "$_truePath")" 51 | # shellcheck disable=SC2193,SC2050 52 | [ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && mkdir -p "$(dirname "${secretType.path}")" 53 | ( 54 | umask u=r,g=,o= 55 | test -f "${secretType.file}" || echo '[agenix] WARNING: encrypted file ${secretType.file} does not exist!' 56 | test -d "$(dirname "$TMP_FILE")" || echo "[agenix] WARNING: $(dirname "$TMP_FILE") does not exist!" 57 | LANG=${config.i18n.defaultLocale or "C"} ${ageBin} --decrypt "''${IDENTITIES[@]}" -o "$TMP_FILE" "${secretType.file}" 58 | ) 59 | chmod ${secretType.mode} "$TMP_FILE" 60 | mv -f "$TMP_FILE" "$_truePath" 61 | 62 | ${optionalString secretType.symlink '' 63 | # shellcheck disable=SC2193,SC2050 64 | [ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && ln -sfT "${cfg.secretsDir}/${secretType.name}" "${secretType.path}" 65 | ''} 66 | ''; 67 | 68 | testIdentities = 69 | map 70 | (path: '' 71 | test -f ${path} || echo '[agenix] WARNING: config.age.identityPaths entry ${path} not present!' 72 | '') 73 | cfg.identityPaths; 74 | 75 | cleanupAndLink = '' 76 | _agenix_generation="$(basename "$(readlink "${cfg.secretsDir}")" || echo 0)" 77 | (( ++_agenix_generation )) 78 | echo "[agenix] symlinking new secrets to ${cfg.secretsDir} (generation $_agenix_generation)..." 79 | ln -sfT "${cfg.secretsMountPoint}/$_agenix_generation" "${cfg.secretsDir}" 80 | 81 | (( _agenix_generation > 1 )) && { 82 | echo "[agenix] removing old secrets (generation $(( _agenix_generation - 1 )))..." 83 | rm -rf "${cfg.secretsMountPoint}/$(( _agenix_generation - 1 ))" 84 | } 85 | ''; 86 | 87 | installSecrets = builtins.concatStringsSep "\n" ( 88 | ["echo '[agenix] decrypting secrets...'"] 89 | ++ testIdentities 90 | ++ (map installSecret (builtins.attrValues cfg.secrets)) 91 | ++ [cleanupAndLink] 92 | ); 93 | 94 | secretType = types.submodule ({ 95 | config, 96 | name, 97 | ... 98 | }: { 99 | options = { 100 | name = mkOption { 101 | type = types.str; 102 | default = name; 103 | description = '' 104 | Name of the file used in ''${cfg.secretsDir} 105 | ''; 106 | }; 107 | file = mkOption { 108 | type = types.path; 109 | description = '' 110 | Age file the secret is loaded from. 111 | ''; 112 | }; 113 | path = mkOption { 114 | type = types.str; 115 | default = "${cfg.secretsDir}/${config.name}"; 116 | description = '' 117 | Path where the decrypted secret is installed. 118 | ''; 119 | }; 120 | mode = mkOption { 121 | type = types.str; 122 | default = "0400"; 123 | description = '' 124 | Permissions mode of the decrypted secret in a format understood by chmod. 125 | ''; 126 | }; 127 | symlink = mkEnableOption "symlinking secrets to their destination" // {default = true;}; 128 | }; 129 | }); 130 | 131 | mountingScript = let 132 | app = pkgs.writeShellApplication { 133 | name = "agenix-home-manager-mount-secrets"; 134 | runtimeInputs = with pkgs; [coreutils]; 135 | text = '' 136 | ${newGeneration} 137 | ${installSecrets} 138 | exit 0 139 | ''; 140 | }; 141 | in 142 | lib.getExe app; 143 | 144 | userDirectory = dir: let 145 | inherit (pkgs.stdenv.hostPlatform) isDarwin; 146 | baseDir = 147 | if isDarwin 148 | then "$(getconf DARWIN_USER_TEMP_DIR)" 149 | else "\${XDG_RUNTIME_DIR}"; 150 | in "${baseDir}/${dir}"; 151 | 152 | userDirectoryDescription = dir: 153 | literalExpression '' 154 | "${XDG_RUNTIME_DIR}"/${dir} on linux or "$(getconf DARWIN_USER_TEMP_DIR)"/${dir} on darwin. 155 | ''; 156 | in { 157 | options.age = { 158 | package = mkPackageOption pkgs "age" {}; 159 | 160 | secrets = mkOption { 161 | type = types.attrsOf secretType; 162 | default = {}; 163 | description = '' 164 | Attrset of secrets. 165 | ''; 166 | }; 167 | 168 | identityPaths = mkOption { 169 | type = types.listOf types.path; 170 | default = [ 171 | "${config.home.homeDirectory}/.ssh/id_ed25519" 172 | "${config.home.homeDirectory}/.ssh/id_rsa" 173 | ]; 174 | defaultText = literalExpression '' 175 | [ 176 | "''${config.home.homeDirectory}/.ssh/id_ed25519" 177 | "''${config.home.homeDirectory}/.ssh/id_rsa" 178 | ] 179 | ''; 180 | description = '' 181 | Path to SSH keys to be used as identities in age decryption. 182 | ''; 183 | }; 184 | 185 | secretsDir = mkOption { 186 | type = types.str; 187 | default = userDirectory "agenix"; 188 | defaultText = userDirectoryDescription "agenix"; 189 | description = '' 190 | Folder where secrets are symlinked to 191 | ''; 192 | }; 193 | 194 | secretsMountPoint = mkOption { 195 | default = userDirectory "agenix.d"; 196 | defaultText = userDirectoryDescription "agenix.d"; 197 | description = '' 198 | Where secrets are created before they are symlinked to ''${cfg.secretsDir} 199 | ''; 200 | }; 201 | }; 202 | 203 | config = mkIf (cfg.secrets != {}) { 204 | assertions = [ 205 | { 206 | assertion = cfg.identityPaths != []; 207 | message = "age.identityPaths must be set."; 208 | } 209 | ]; 210 | 211 | systemd.user.services.agenix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { 212 | Unit = { 213 | Description = "agenix activation"; 214 | }; 215 | Service = { 216 | Type = "oneshot"; 217 | ExecStart = mountingScript; 218 | }; 219 | Install.WantedBy = ["default.target"]; 220 | }; 221 | 222 | launchd.agents.activate-agenix = { 223 | enable = true; 224 | config = { 225 | ProgramArguments = [mountingScript]; 226 | KeepAlive = { 227 | Crashed = false; 228 | SuccessfulExit = false; 229 | }; 230 | RunAtLoad = true; 231 | ProcessType = "Background"; 232 | StandardOutPath = "${config.home.homeDirectory}/Library/Logs/agenix/stdout"; 233 | StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/agenix/stderr"; 234 | }; 235 | }; 236 | }; 237 | } 238 | -------------------------------------------------------------------------------- /modules/age.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | options, 4 | lib, 5 | pkgs, 6 | ... 7 | }: 8 | with lib; let 9 | cfg = config.age; 10 | 11 | isDarwin = lib.attrsets.hasAttrByPath ["environment" "darwinConfig"] options; 12 | 13 | ageBin = config.age.ageBin; 14 | 15 | users = config.users.users; 16 | 17 | mountCommand = 18 | if isDarwin 19 | then '' 20 | if ! diskutil info "${cfg.secretsMountPoint}" &> /dev/null; then 21 | num_sectors=1048576 22 | dev=$(hdiutil attach -nomount ram://"$num_sectors" | sed 's/[[:space:]]*$//') 23 | newfs_hfs -v agenix "$dev" 24 | mount -t hfs -o nobrowse,nodev,nosuid,-m=0751 "$dev" "${cfg.secretsMountPoint}" 25 | fi 26 | '' 27 | else '' 28 | grep -q "${cfg.secretsMountPoint} ramfs" /proc/mounts || 29 | mount -t ramfs none "${cfg.secretsMountPoint}" -o nodev,nosuid,mode=0751 30 | ''; 31 | newGeneration = '' 32 | _agenix_generation="$(basename "$(readlink ${cfg.secretsDir})" || echo 0)" 33 | (( ++_agenix_generation )) 34 | echo "[agenix] creating new generation in ${cfg.secretsMountPoint}/$_agenix_generation" 35 | mkdir -p "${cfg.secretsMountPoint}" 36 | chmod 0751 "${cfg.secretsMountPoint}" 37 | ${mountCommand} 38 | mkdir -p "${cfg.secretsMountPoint}/$_agenix_generation" 39 | chmod 0751 "${cfg.secretsMountPoint}/$_agenix_generation" 40 | ''; 41 | 42 | chownGroup = 43 | if isDarwin 44 | then "admin" 45 | else "keys"; 46 | # chown the secrets mountpoint and the current generation to the keys group 47 | # instead of leaving it root:root. 48 | chownMountPoint = '' 49 | chown :${chownGroup} "${cfg.secretsMountPoint}" "${cfg.secretsMountPoint}/$_agenix_generation" 50 | ''; 51 | 52 | setTruePath = secretType: '' 53 | ${ 54 | if secretType.symlink 55 | then '' 56 | _truePath="${cfg.secretsMountPoint}/$_agenix_generation/${secretType.name}" 57 | '' 58 | else '' 59 | _truePath="${secretType.path}" 60 | '' 61 | } 62 | ''; 63 | 64 | installSecret = secretType: '' 65 | ${setTruePath secretType} 66 | echo "decrypting '${secretType.file}' to '$_truePath'..." 67 | TMP_FILE="$_truePath.tmp" 68 | 69 | IDENTITIES=() 70 | for identity in ${toString cfg.identityPaths}; do 71 | test -r "$identity" || continue 72 | test -s "$identity" || continue 73 | IDENTITIES+=(-i) 74 | IDENTITIES+=("$identity") 75 | done 76 | 77 | test "''${#IDENTITIES[@]}" -eq 0 && echo "[agenix] WARNING: no readable identities found!" 78 | 79 | mkdir -p "$(dirname "$_truePath")" 80 | [ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && mkdir -p "$(dirname "${secretType.path}")" 81 | ( 82 | umask u=r,g=,o= 83 | test -f "${secretType.file}" || echo '[agenix] WARNING: encrypted file ${secretType.file} does not exist!' 84 | test -d "$(dirname "$TMP_FILE")" || echo "[agenix] WARNING: $(dirname "$TMP_FILE") does not exist!" 85 | LANG=${config.i18n.defaultLocale or "C"} ${ageBin} --decrypt "''${IDENTITIES[@]}" -o "$TMP_FILE" "${secretType.file}" 86 | ) 87 | chmod ${secretType.mode} "$TMP_FILE" 88 | mv -f "$TMP_FILE" "$_truePath" 89 | 90 | ${optionalString secretType.symlink '' 91 | [ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && ln -sfT "${cfg.secretsDir}/${secretType.name}" "${secretType.path}" 92 | ''} 93 | ''; 94 | 95 | testIdentities = 96 | map 97 | (path: '' 98 | test -f ${path} || echo '[agenix] WARNING: config.age.identityPaths entry ${path} not present!' 99 | '') 100 | cfg.identityPaths; 101 | 102 | cleanupAndLink = '' 103 | _agenix_generation="$(basename "$(readlink ${cfg.secretsDir})" || echo 0)" 104 | (( ++_agenix_generation )) 105 | echo "[agenix] symlinking new secrets to ${cfg.secretsDir} (generation $_agenix_generation)..." 106 | ln -sfT "${cfg.secretsMountPoint}/$_agenix_generation" ${cfg.secretsDir} 107 | 108 | (( _agenix_generation > 1 )) && { 109 | echo "[agenix] removing old secrets (generation $(( _agenix_generation - 1 )))..." 110 | rm -rf "${cfg.secretsMountPoint}/$(( _agenix_generation - 1 ))" 111 | } 112 | ''; 113 | 114 | installSecrets = builtins.concatStringsSep "\n" ( 115 | ["echo '[agenix] decrypting secrets...'"] 116 | ++ testIdentities 117 | ++ (map installSecret (builtins.attrValues cfg.secrets)) 118 | ++ [cleanupAndLink] 119 | ); 120 | 121 | chownSecret = secretType: '' 122 | ${setTruePath secretType} 123 | chown ${secretType.owner}:${secretType.group} "$_truePath" 124 | ''; 125 | 126 | chownSecrets = builtins.concatStringsSep "\n" ( 127 | ["echo '[agenix] chowning...'"] 128 | ++ [chownMountPoint] 129 | ++ (map chownSecret (builtins.attrValues cfg.secrets)) 130 | ); 131 | 132 | secretType = types.submodule ({config, ...}: { 133 | options = { 134 | name = mkOption { 135 | type = types.str; 136 | default = config._module.args.name; 137 | defaultText = literalExpression "config._module.args.name"; 138 | description = '' 139 | Name of the file used in {option}`age.secretsDir` 140 | ''; 141 | }; 142 | file = mkOption { 143 | type = types.path; 144 | description = '' 145 | Age file the secret is loaded from. 146 | ''; 147 | }; 148 | path = mkOption { 149 | type = types.str; 150 | default = "${cfg.secretsDir}/${config.name}"; 151 | defaultText = literalExpression '' 152 | "''${cfg.secretsDir}/''${config.name}" 153 | ''; 154 | description = '' 155 | Path where the decrypted secret is installed. 156 | ''; 157 | }; 158 | mode = mkOption { 159 | type = types.str; 160 | default = "0400"; 161 | description = '' 162 | Permissions mode of the decrypted secret in a format understood by chmod. 163 | ''; 164 | }; 165 | owner = mkOption { 166 | type = types.str; 167 | default = "0"; 168 | description = '' 169 | User of the decrypted secret. 170 | ''; 171 | }; 172 | group = mkOption { 173 | type = types.str; 174 | default = users.${config.owner}.group or "0"; 175 | defaultText = literalExpression '' 176 | users.''${config.owner}.group or "0" 177 | ''; 178 | description = '' 179 | Group of the decrypted secret. 180 | ''; 181 | }; 182 | symlink = mkEnableOption "symlinking secrets to their destination" // {default = true;}; 183 | }; 184 | }); 185 | in { 186 | imports = [ 187 | (mkRenamedOptionModule ["age" "sshKeyPaths"] ["age" "identityPaths"]) 188 | ]; 189 | 190 | options.age = { 191 | ageBin = mkOption { 192 | type = types.str; 193 | default = "${pkgs.age}/bin/age"; 194 | defaultText = literalExpression '' 195 | "''${pkgs.age}/bin/age" 196 | ''; 197 | description = '' 198 | The age executable to use. 199 | ''; 200 | }; 201 | secrets = mkOption { 202 | type = types.attrsOf secretType; 203 | default = {}; 204 | description = '' 205 | Attrset of secrets. 206 | ''; 207 | }; 208 | secretsDir = mkOption { 209 | type = types.path; 210 | default = "/run/agenix"; 211 | description = '' 212 | Folder where secrets are symlinked to 213 | ''; 214 | }; 215 | secretsMountPoint = mkOption { 216 | type = 217 | types.addCheck types.str 218 | (s: 219 | (builtins.match "[ \t\n]*" s) 220 | == null # non-empty 221 | && (builtins.match ".+/" s) == null) # without trailing slash 222 | // {description = "${types.str.description} (with check: non-empty without trailing slash)";}; 223 | default = "/run/agenix.d"; 224 | description = '' 225 | Where secrets are created before they are symlinked to {option}`age.secretsDir` 226 | ''; 227 | }; 228 | identityPaths = mkOption { 229 | type = types.listOf types.path; 230 | default = 231 | if isDarwin 232 | then [ 233 | "/etc/ssh/ssh_host_ed25519_key" 234 | "/etc/ssh/ssh_host_rsa_key" 235 | ] 236 | else if (config.services.openssh.enable or false) 237 | then map (e: e.path) (lib.filter (e: e.type == "rsa" || e.type == "ed25519") config.services.openssh.hostKeys) 238 | else []; 239 | defaultText = literalExpression '' 240 | if isDarwin 241 | then [ 242 | "/etc/ssh/ssh_host_ed25519_key" 243 | "/etc/ssh/ssh_host_rsa_key" 244 | ] 245 | else if (config.services.openssh.enable or false) 246 | then map (e: e.path) (lib.filter (e: e.type == "rsa" || e.type == "ed25519") config.services.openssh.hostKeys) 247 | else []; 248 | ''; 249 | description = '' 250 | Path to SSH keys to be used as identities in age decryption. 251 | ''; 252 | }; 253 | }; 254 | 255 | config = mkIf (cfg.secrets != {}) (mkMerge [ 256 | { 257 | assertions = [ 258 | { 259 | assertion = cfg.identityPaths != []; 260 | message = "age.identityPaths must be set."; 261 | } 262 | ]; 263 | } 264 | 265 | (optionalAttrs (!isDarwin) { 266 | # Create a new directory full of secrets for symlinking (this helps 267 | # ensure removed secrets are actually removed, or at least become 268 | # invalid symlinks). 269 | system.activationScripts.agenixNewGeneration = { 270 | text = newGeneration; 271 | deps = [ 272 | "specialfs" 273 | ]; 274 | }; 275 | 276 | system.activationScripts.agenixInstall = { 277 | text = installSecrets; 278 | deps = [ 279 | "agenixNewGeneration" 280 | "specialfs" 281 | ]; 282 | }; 283 | 284 | # So user passwords can be encrypted. 285 | system.activationScripts.users.deps = ["agenixInstall"]; 286 | 287 | # Change ownership and group after users and groups are made. 288 | system.activationScripts.agenixChown = { 289 | text = chownSecrets; 290 | deps = [ 291 | "users" 292 | "groups" 293 | ]; 294 | }; 295 | 296 | # So other activation scripts can depend on agenix being done. 297 | system.activationScripts.agenix = { 298 | text = ""; 299 | deps = ["agenixChown"]; 300 | }; 301 | }) 302 | (optionalAttrs isDarwin { 303 | launchd.daemons.activate-agenix = { 304 | script = '' 305 | set -e 306 | set -o pipefail 307 | export PATH="${pkgs.gnugrep}/bin:${pkgs.coreutils}/bin:@out@/sw/bin:/usr/bin:/bin:/usr/sbin:/sbin" 308 | ${newGeneration} 309 | ${installSecrets} 310 | ${chownSecrets} 311 | exit 0 312 | ''; 313 | serviceConfig = { 314 | RunAtLoad = true; 315 | KeepAlive.SuccessfulExit = false; 316 | }; 317 | }; 318 | }) 319 | ]); 320 | } 321 | -------------------------------------------------------------------------------- /overlay.nix: -------------------------------------------------------------------------------- 1 | final: prev: { 2 | agenix = prev.callPackage ./pkgs/agenix.nix {}; 3 | } 4 | -------------------------------------------------------------------------------- /pkgs/agenix.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | stdenv, 4 | age, 5 | jq, 6 | nix, 7 | mktemp, 8 | diffutils, 9 | replaceVars, 10 | ageBin ? "${age}/bin/age", 11 | shellcheck, 12 | }: let 13 | bin = "${placeholder "out"}/bin/agenix"; 14 | in 15 | stdenv.mkDerivation rec { 16 | pname = "agenix"; 17 | version = "0.15.0"; 18 | src = replaceVars ./agenix.sh { 19 | inherit ageBin version; 20 | jqBin = "${jq}/bin/jq"; 21 | nixInstantiate = "${nix}/bin/nix-instantiate"; 22 | mktempBin = "${mktemp}/bin/mktemp"; 23 | diffBin = "${diffutils}/bin/diff"; 24 | }; 25 | dontUnpack = true; 26 | doInstallCheck = true; 27 | installCheckInputs = [shellcheck]; 28 | postInstallCheck = '' 29 | shellcheck ${bin} 30 | ${bin} -h | grep ${version} 31 | 32 | test_tmp=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir') 33 | export HOME="$test_tmp/home" 34 | export NIX_STORE_DIR="$test_tmp/nix/store" 35 | export NIX_STATE_DIR="$test_tmp/nix/var" 36 | mkdir -p "$HOME" "$NIX_STORE_DIR" "$NIX_STATE_DIR" 37 | function cleanup { 38 | rm -rf "$test_tmp" 39 | } 40 | trap "cleanup" 0 2 3 15 41 | 42 | mkdir -p $HOME/.ssh 43 | cp -r "${../example}" $HOME/secrets 44 | chmod -R u+rw $HOME/secrets 45 | ( 46 | umask u=rw,g=r,o=r 47 | cp ${../example_keys/user1.pub} $HOME/.ssh/id_ed25519.pub 48 | chown $UID $HOME/.ssh/id_ed25519.pub 49 | ) 50 | ( 51 | umask u=rw,g=,o= 52 | cp ${../example_keys/user1} $HOME/.ssh/id_ed25519 53 | chown $UID $HOME/.ssh/id_ed25519 54 | ) 55 | 56 | cd $HOME/secrets 57 | test $(${bin} -d secret1.age) = "hello" 58 | ''; 59 | 60 | installPhase = '' 61 | install -D $src ${bin} 62 | ''; 63 | 64 | meta.description = "age-encrypted secrets for NixOS"; 65 | } 66 | -------------------------------------------------------------------------------- /pkgs/agenix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -Eeuo pipefail 3 | 4 | PACKAGE="agenix" 5 | 6 | function show_help () { 7 | echo "$PACKAGE - edit and rekey age secret files" 8 | echo " " 9 | echo "$PACKAGE -e FILE [-i PRIVATE_KEY]" 10 | echo "$PACKAGE -r [-i PRIVATE_KEY]" 11 | echo ' ' 12 | echo 'options:' 13 | echo '-h, --help show help' 14 | # shellcheck disable=SC2016 15 | echo '-e, --edit FILE edits FILE using $EDITOR' 16 | echo '-r, --rekey re-encrypts all secrets with specified recipients' 17 | echo '-d, --decrypt FILE decrypts FILE to STDOUT' 18 | echo '-i, --identity identity to use when decrypting' 19 | echo '-v, --verbose verbose output' 20 | echo ' ' 21 | echo 'FILE an age-encrypted file' 22 | echo ' ' 23 | echo 'PRIVATE_KEY a path to a private SSH key used to decrypt file' 24 | echo ' ' 25 | echo 'EDITOR environment variable of editor to use when editing FILE' 26 | echo ' ' 27 | echo 'If STDIN is not interactive, EDITOR will be set to "cp /dev/stdin"' 28 | echo ' ' 29 | echo 'RULES environment variable with path to Nix file specifying recipient public keys.' 30 | echo "Defaults to './secrets.nix'" 31 | echo ' ' 32 | echo "agenix version: @version@" 33 | echo "age binary path: @ageBin@" 34 | echo "age version: $(@ageBin@ --version)" 35 | } 36 | 37 | function warn() { 38 | printf '%s\n' "$*" >&2 39 | } 40 | 41 | function err() { 42 | warn "$*" 43 | exit 1 44 | } 45 | 46 | test $# -eq 0 && (show_help && exit 1) 47 | 48 | REKEY=0 49 | DECRYPT_ONLY=0 50 | DEFAULT_DECRYPT=(--decrypt) 51 | 52 | while test $# -gt 0; do 53 | case "$1" in 54 | -h|--help) 55 | show_help 56 | exit 0 57 | ;; 58 | -e|--edit) 59 | shift 60 | if test $# -gt 0; then 61 | export FILE=$1 62 | else 63 | echo "no FILE specified" 64 | exit 1 65 | fi 66 | shift 67 | ;; 68 | -i|--identity) 69 | shift 70 | if test $# -gt 0; then 71 | DEFAULT_DECRYPT+=(--identity "$1") 72 | else 73 | echo "no PRIVATE_KEY specified" 74 | exit 1 75 | fi 76 | shift 77 | ;; 78 | -r|--rekey) 79 | shift 80 | REKEY=1 81 | ;; 82 | -d|--decrypt) 83 | shift 84 | DECRYPT_ONLY=1 85 | if test $# -gt 0; then 86 | export FILE=$1 87 | else 88 | echo "no FILE specified" 89 | exit 1 90 | fi 91 | shift 92 | ;; 93 | -v|--verbose) 94 | shift 95 | set -x 96 | ;; 97 | *) 98 | show_help 99 | exit 1 100 | ;; 101 | esac 102 | done 103 | 104 | RULES=${RULES:-./secrets.nix} 105 | function cleanup { 106 | if [ -n "${CLEARTEXT_DIR+x}" ] 107 | then 108 | rm -rf -- "$CLEARTEXT_DIR" 109 | fi 110 | if [ -n "${REENCRYPTED_DIR+x}" ] 111 | then 112 | rm -rf -- "$REENCRYPTED_DIR" 113 | fi 114 | } 115 | trap "cleanup" 0 2 3 15 116 | 117 | function keys { 118 | (@nixInstantiate@ --json --eval --strict -E "(let rules = import $RULES; in rules.\"$1\".publicKeys)" | @jqBin@ -r .[]) || exit 1 119 | } 120 | 121 | function decrypt { 122 | FILE=$1 123 | KEYS=$2 124 | if [ -z "$KEYS" ] 125 | then 126 | err "There is no rule for $FILE in $RULES." 127 | fi 128 | 129 | if [ -f "$FILE" ] 130 | then 131 | DECRYPT=("${DEFAULT_DECRYPT[@]}") 132 | if [[ "${DECRYPT[*]}" != *"--identity"* ]]; then 133 | if [ -f "$HOME/.ssh/id_rsa" ]; then 134 | DECRYPT+=(--identity "$HOME/.ssh/id_rsa") 135 | fi 136 | if [ -f "$HOME/.ssh/id_ed25519" ]; then 137 | DECRYPT+=(--identity "$HOME/.ssh/id_ed25519") 138 | fi 139 | fi 140 | if [[ "${DECRYPT[*]}" != *"--identity"* ]]; then 141 | err "No identity found to decrypt $FILE. Try adding an SSH key at $HOME/.ssh/id_rsa or $HOME/.ssh/id_ed25519 or using the --identity flag to specify a file." 142 | fi 143 | 144 | @ageBin@ "${DECRYPT[@]}" -- "$FILE" || exit 1 145 | fi 146 | } 147 | 148 | function edit { 149 | FILE=$1 150 | KEYS=$(keys "$FILE") || exit 1 151 | 152 | CLEARTEXT_DIR=$(@mktempBin@ -d) 153 | CLEARTEXT_FILE="$CLEARTEXT_DIR/$(basename -- "$FILE")" 154 | DEFAULT_DECRYPT+=(-o "$CLEARTEXT_FILE") 155 | 156 | decrypt "$FILE" "$KEYS" || exit 1 157 | 158 | [ ! -f "$CLEARTEXT_FILE" ] || cp -- "$CLEARTEXT_FILE" "$CLEARTEXT_FILE.before" 159 | 160 | [ -t 0 ] || EDITOR='cp -- /dev/stdin' 161 | 162 | $EDITOR "$CLEARTEXT_FILE" 163 | 164 | if [ ! -f "$CLEARTEXT_FILE" ] 165 | then 166 | warn "$FILE wasn't created." 167 | return 168 | fi 169 | [ -f "$FILE" ] && [ "$EDITOR" != ":" ] && @diffBin@ -q -- "$CLEARTEXT_FILE.before" "$CLEARTEXT_FILE" && warn "$FILE wasn't changed, skipping re-encryption." && return 170 | 171 | ENCRYPT=() 172 | while IFS= read -r key 173 | do 174 | if [ -n "$key" ]; then 175 | ENCRYPT+=(--recipient "$key") 176 | fi 177 | done <<< "$KEYS" 178 | 179 | REENCRYPTED_DIR=$(@mktempBin@ -d) 180 | REENCRYPTED_FILE="$REENCRYPTED_DIR/$(basename -- "$FILE")" 181 | 182 | ENCRYPT+=(-o "$REENCRYPTED_FILE") 183 | 184 | @ageBin@ "${ENCRYPT[@]}" <"$CLEARTEXT_FILE" || exit 1 185 | 186 | mkdir -p -- "$(dirname -- "$FILE")" 187 | 188 | mv -f -- "$REENCRYPTED_FILE" "$FILE" 189 | } 190 | 191 | function rekey { 192 | FILES=$( (@nixInstantiate@ --json --eval -E "(let rules = import $RULES; in builtins.attrNames rules)" | @jqBin@ -r .[]) || exit 1) 193 | 194 | for FILE in $FILES 195 | do 196 | warn "rekeying $FILE..." 197 | EDITOR=: edit "$FILE" 198 | cleanup 199 | done 200 | } 201 | 202 | [ $REKEY -eq 1 ] && rekey && exit 0 203 | [ $DECRYPT_ONLY -eq 1 ] && DEFAULT_DECRYPT+=("-o" "-") && decrypt "${FILE}" "$(keys "$FILE")" && exit 0 204 | edit "$FILE" && cleanup && exit 0 205 | -------------------------------------------------------------------------------- /pkgs/doc.nix: -------------------------------------------------------------------------------- 1 | { 2 | stdenvNoCC, 3 | mmdoc, 4 | self, 5 | }: 6 | stdenvNoCC.mkDerivation rec { 7 | name = "agenix-doc"; 8 | src = ../doc; 9 | phases = ["mmdocPhase"]; 10 | mmdocPhase = "${mmdoc}/bin/mmdoc agenix $src $out"; 11 | } 12 | -------------------------------------------------------------------------------- /test/install_ssh_host_keys.nix: -------------------------------------------------------------------------------- 1 | # Do not copy this! It is insecure. This is only okay because we are testing. 2 | {config, ...}: { 3 | system.activationScripts.agenixInstall.deps = ["installSSHHostKeys"]; 4 | 5 | system.activationScripts.installSSHHostKeys.text = '' 6 | USER1_UID="${toString config.users.users.user1.uid}" 7 | USERS_GID="${toString config.users.groups.users.gid}" 8 | 9 | mkdir -p /etc/ssh /home/user1/.ssh 10 | chown $USER1_UID:$USERS_GID /home/user1/.ssh 11 | ( 12 | umask u=rw,g=r,o=r 13 | cp ${../example_keys/system1.pub} /etc/ssh/ssh_host_ed25519_key.pub 14 | cp ${../example_keys/user1.pub} /home/user1/.ssh/id_ed25519.pub 15 | chown $USER1_UID:$USERS_GID /home/user1/.ssh/id_ed25519.pub 16 | ) 17 | ( 18 | umask u=rw,g=,o= 19 | cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key 20 | cp ${../example_keys/user1} /home/user1/.ssh/id_ed25519 21 | chown $USER1_UID:$USERS_GID /home/user1/.ssh/id_ed25519 22 | touch /etc/ssh/ssh_host_rsa_key 23 | ) 24 | cp -r "${../example}" /tmp/secrets 25 | chmod -R u+rw /tmp/secrets 26 | chown -R $USER1_UID:$USERS_GID /tmp/secrets 27 | ''; 28 | } 29 | -------------------------------------------------------------------------------- /test/install_ssh_host_keys_darwin.nix: -------------------------------------------------------------------------------- 1 | # Do not copy this! It is insecure. This is only okay because we are testing. 2 | { 3 | system.activationScripts.extraUserActivation.text = '' 4 | echo "Installing system SSH host key" 5 | sudo cp ${../example_keys/system1.pub} /etc/ssh/ssh_host_ed25519_key.pub 6 | sudo cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key 7 | sudo chmod 644 /etc/ssh/ssh_host_ed25519_key.pub 8 | sudo chmod 600 /etc/ssh/ssh_host_ed25519_key 9 | 10 | echo "Installing user SSH host key" 11 | mkdir -p "$HOME/.ssh" 12 | cp ${../example_keys/user1.pub} "$HOME/.ssh/id_ed25519.pub" 13 | cp ${../example_keys/user1} "$HOME/.ssh/id_ed25519" 14 | chmod 644 "$HOME/.ssh/id_ed25519.pub" 15 | chmod 600 "$HOME/.ssh/id_ed25519" 16 | ''; 17 | } 18 | -------------------------------------------------------------------------------- /test/integration.nix: -------------------------------------------------------------------------------- 1 | { 2 | nixpkgs ? , 3 | pkgs ? 4 | import { 5 | inherit system; 6 | config = {}; 7 | }, 8 | system ? builtins.currentSystem, 9 | home-manager ? , 10 | }: 11 | pkgs.nixosTest { 12 | name = "agenix-integration"; 13 | nodes.system1 = { 14 | config, 15 | pkgs, 16 | options, 17 | ... 18 | }: { 19 | imports = [ 20 | ../modules/age.nix 21 | ./install_ssh_host_keys.nix 22 | "${home-manager}/nixos" 23 | ]; 24 | 25 | services.openssh.enable = true; 26 | 27 | age.secrets = { 28 | passwordfile-user1.file = ../example/passwordfile-user1.age; 29 | leading-hyphen.file = ../example/-leading-hyphen-filename.age; 30 | }; 31 | 32 | age.identityPaths = options.age.identityPaths.default ++ ["/etc/ssh/this_key_wont_exist"]; 33 | 34 | environment.systemPackages = [ 35 | (pkgs.callPackage ../pkgs/agenix.nix {}) 36 | ]; 37 | 38 | users = { 39 | mutableUsers = false; 40 | 41 | users = { 42 | user1 = { 43 | isNormalUser = true; 44 | passwordFile = config.age.secrets.passwordfile-user1.path; 45 | uid = 1000; 46 | }; 47 | }; 48 | }; 49 | 50 | home-manager.users.user1 = {options, ...}: { 51 | imports = [ 52 | ../modules/age-home.nix 53 | ]; 54 | 55 | home.stateVersion = pkgs.lib.trivial.release; 56 | 57 | age = { 58 | identityPaths = options.age.identityPaths.default ++ ["/home/user1/.ssh/this_key_wont_exist"]; 59 | secrets.secret2 = { 60 | # Only decryptable by user1's key 61 | file = ../example/secret2.age; 62 | }; 63 | secrets.secret2Path = { 64 | file = ../example/secret2.age; 65 | path = "/home/user1/secret2"; 66 | }; 67 | }; 68 | }; 69 | }; 70 | 71 | testScript = let 72 | user = "user1"; 73 | password = "password1234"; 74 | secret2 = "world!"; 75 | hyphen-secret = "filename started with hyphen"; 76 | in '' 77 | system1.wait_for_unit("multi-user.target") 78 | system1.wait_until_succeeds("pgrep -f 'agetty.*tty1'") 79 | system1.sleep(2) 80 | system1.send_key("alt-f2") 81 | system1.wait_until_succeeds("[ $(fgconsole) = 2 ]") 82 | system1.wait_for_unit("getty@tty2.service") 83 | system1.wait_until_succeeds("pgrep -f 'agetty.*tty2'") 84 | system1.wait_until_tty_matches("2", "login: ") 85 | system1.send_chars("${user}\n") 86 | system1.wait_until_tty_matches("2", "login: ${user}") 87 | system1.wait_until_succeeds("pgrep login") 88 | system1.sleep(2) 89 | system1.send_chars("${password}\n") 90 | system1.send_chars("whoami > /tmp/1\n") 91 | system1.wait_for_file("/tmp/1") 92 | assert "${user}" in system1.succeed("cat /tmp/1") 93 | system1.send_chars("cat /run/user/$(id -u)/agenix/secret2 > /tmp/2\n") 94 | system1.wait_for_file("/tmp/2") 95 | assert "${secret2}" in system1.succeed("cat /tmp/2") 96 | 97 | assert "${hyphen-secret}" in system1.succeed("cat /run/agenix/leading-hyphen") 98 | 99 | userDo = lambda input : f"sudo -u user1 -- bash -c 'set -eou pipefail; cd /tmp/secrets; {input}'" 100 | 101 | before_hash = system1.succeed(userDo('sha256sum passwordfile-user1.age')).split() 102 | print(system1.succeed(userDo('agenix -r -i /home/user1/.ssh/id_ed25519'))) 103 | after_hash = system1.succeed(userDo('sha256sum passwordfile-user1.age')).split() 104 | 105 | # Ensure we actually have hashes 106 | for h in [before_hash, after_hash]: 107 | assert len(h) == 2, "hash should be [hash, filename]" 108 | assert h[1] == "passwordfile-user1.age", "filename is incorrect" 109 | assert len(h[0].strip()) == 64, "hash length is incorrect" 110 | assert before_hash[0] != after_hash[0], "hash did not change with rekeying" 111 | 112 | # user1 can edit passwordfile-user1.age 113 | system1.succeed(userDo("EDITOR=cat agenix -e passwordfile-user1.age")) 114 | 115 | # user1 can edit even if bogus id_rsa present 116 | system1.succeed(userDo("echo bogus > ~/.ssh/id_rsa")) 117 | system1.fail(userDo("EDITOR=cat agenix -e passwordfile-user1.age")) 118 | system1.succeed(userDo("EDITOR=cat agenix -e passwordfile-user1.age -i /home/user1/.ssh/id_ed25519")) 119 | system1.succeed(userDo("rm ~/.ssh/id_rsa")) 120 | 121 | # user1 can edit a secret by piping in contents 122 | system1.succeed(userDo("echo 'secret1234' | agenix -e passwordfile-user1.age")) 123 | 124 | # and get it back out via --decrypt 125 | assert "secret1234" in system1.succeed(userDo("agenix -d passwordfile-user1.age")) 126 | 127 | # finally, the plain text should not linger around anywhere in the filesystem. 128 | system1.fail("grep -r secret1234 /tmp") 129 | ''; 130 | } 131 | -------------------------------------------------------------------------------- /test/integration_darwin.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | pkgs, 4 | options, 5 | ... 6 | }: let 7 | secret = "hello"; 8 | testScript = pkgs.writeShellApplication { 9 | name = "agenix-integration"; 10 | text = '' 11 | grep "${secret}" "${config.age.secrets.system-secret.path}" 12 | ''; 13 | }; 14 | in { 15 | imports = [ 16 | ./install_ssh_host_keys_darwin.nix 17 | ../modules/age.nix 18 | ]; 19 | 20 | age = { 21 | identityPaths = options.age.identityPaths.default ++ ["/etc/ssh/this_key_wont_exist"]; 22 | secrets.system-secret.file = ../example/secret1.age; 23 | }; 24 | 25 | environment.systemPackages = [testScript]; 26 | 27 | system.stateVersion = 6; 28 | } 29 | -------------------------------------------------------------------------------- /test/integration_hm_darwin.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | config, 4 | options, 5 | lib, 6 | ... 7 | }: { 8 | imports = [../modules/age-home.nix]; 9 | 10 | age = { 11 | identityPaths = options.age.identityPaths.default ++ ["/Users/user1/.ssh/this_key_wont_exist"]; 12 | secrets.user-secret.file = ../example/secret2.age; 13 | }; 14 | 15 | home = rec { 16 | username = "runner"; 17 | homeDirectory = lib.mkForce "/Users/${username}"; 18 | stateVersion = lib.trivial.release; 19 | }; 20 | 21 | home.file = let 22 | name = "agenix-home-integration"; 23 | in { 24 | ${name}.source = pkgs.writeShellApplication { 25 | inherit name; 26 | text = let 27 | secret = "world!"; 28 | in '' 29 | diff -q "${config.age.secrets.user-secret.path}" <(printf '${secret}\n') 30 | ''; 31 | }; 32 | }; 33 | } 34 | --------------------------------------------------------------------------------