├── .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 |
--------------------------------------------------------------------------------