├── flake.nix
├── flake.lock
├── LICENSE
├── README.md
└── module
└── default.nix
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "Home manager secret management with age";
3 |
4 | outputs = {
5 | self,
6 | nixpkgs,
7 | }: {
8 | homeManagerModules.homeage = import ./module;
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "nixpkgs": {
4 | "locked": {
5 | "lastModified": 1636281509,
6 | "narHash": "sha256-oQL+ZMoWTASLOhR7vXrKaQ/dmbGwxgafpG0cyuARvv0=",
7 | "owner": "NixOS",
8 | "repo": "nixpkgs",
9 | "rev": "f6a3eba5874c11d0e13e40522055347e587e5140",
10 | "type": "github"
11 | },
12 | "original": {
13 | "id": "nixpkgs",
14 | "type": "indirect"
15 | }
16 | },
17 | "root": {
18 | "inputs": {
19 | "nixpkgs": "nixpkgs"
20 | }
21 | }
22 | },
23 | "root": "root",
24 | "version": 7
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Jordan Isaacs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # homeage - runtime decrypted [age](https://github.com/str4d/rage) secrets for nix home manager
2 |
3 | `homeage` is a module for [home-manager](https://github.com/nix-community/home-manager) that enables runtime decryption of declarative age files.
4 |
5 | ## Features
6 |
7 | - [x] File agnostic declarative secrets that can be used inside your home-manager flakes
8 | - [X] Symlink (or copy if symlinks aren't supported) decrypted secrets
9 | - [X] Safely cleans up secrets on generation change or systemd service stop
10 | - [x] Encryption is normal age encryption, use your ssh or age keys
11 | - [X] Decryption/cleanup secrets either through systemd services or home-manager activation (for systems without systemd support)
12 |
13 | ## Management Scheme
14 |
15 | Pre-Build:
16 |
17 | * Encrypt files with age and make them accessible to home-manager config (through git repository, builtin function, etc.).
18 | * Install your age/ssh key outside of the scope home-manager.
19 |
20 | Post-build:
21 |
22 | * Encrypted files are copied into the nix store (globally available).
23 | * Scripts for decrypting are in the nix store (globally available).
24 | * Because of this **must** to make sure your decryption key has correct file permissions set.
25 |
26 | ### Systemd Installation
27 |
28 | Service Start:
29 | * Decrypts secret and copies/symlinks to locations
30 |
31 | Service Stop:
32 | * Cleans up decrypted secret and associated copies/symlinks
33 |
34 | Home-manager activation:
35 | * With home-manager systemd reload enabled services will automatically reload/stop during activation for seamless cleanup and re-installation.
36 |
37 | ### Activation Installation
38 |
39 | Home-manager activation:
40 | * Cleans up all secrets that changed between current and previous generation
41 | * Decrypts secret and copies/symlinks to locations
42 |
43 | ## Getting started
44 |
45 | ### Non-flake
46 |
47 | If you are using homeage without nix flakes feel free to contribute an example config.
48 |
49 | ### Nix Flakes
50 |
51 | Import `homeage.homeManagerModules.homeage` into the configuration and set valid `homeage.identityPaths` and your all set.
52 |
53 | ```nix
54 | {
55 | inputs = {
56 | nixpkgs.url = "nixpkgs/nixos-unstable";
57 | home-manager = {
58 | url = "github:nix-community/home-manager";
59 | inputs.nixpkgs.follows = "nixpkgs";
60 | };
61 | homeage = {
62 | url = "github:jordanisaacs/homeage";
63 | # Optional
64 | inputs.nixpkgs.follows = "nixpkgs";
65 | };
66 | };
67 |
68 | outputs = { nixpkgs, homeage, ... }@inputs:
69 | let
70 | pkgs = import nixpkgs {
71 | inherit system;
72 | };
73 |
74 | system = "x86_64-linux";
75 | username = "jd";
76 | stateVersion = "21.05";
77 | in {
78 | homeManagerConfigurations = {
79 | jd = home-manager.lib.homeManagerConfiguration {
80 | inherit system stateVersion username pkgs;
81 | home.homeDirectory = "/home/${username}";
82 |
83 | configuration = {
84 | home.stateVersion = stateVersion;
85 | home.username = username;
86 | home.homeDirectory = "/home/${username}";
87 |
88 | homeage = {
89 | # Absolute path to identity (created not through home-manager)
90 | identityPaths = [ "~/.ssh/id_ed25519" ];
91 |
92 | # "activation" if system doesn't support systemd
93 | installationType = "systemd";
94 |
95 | file."pijulsecretkey" = {
96 | # Path to encrypted file tracked by the git repository
97 | source = ./secretkey.json.age;
98 | symlinks = [ "${config.xdg.configHome}/pijul/secretkey.json" ];
99 | copies = [ "${config.xdg.configHome}/no-symlink-support/secretkey.json" ];
100 | };
101 | };
102 |
103 | imports = [ homeage.homeManagerModules.homeage ];
104 | };
105 | };
106 | };
107 | };
108 | }
109 | ```
110 |
111 | ## Options
112 |
113 | See [source](./module/default.nix) for all the options and their descriptions.
114 |
115 | ## Acknowledgments
116 |
117 | The inspiration for this came from RaitoBezarius' [pull request](https://github.com/ryantm/agenix/pull/58/files) to agenix. I have been trying to figure out how to do secrets with home manager for a while and that PR laid out the foundational ideas for how to do it!
118 |
--------------------------------------------------------------------------------
/module/default.nix:
--------------------------------------------------------------------------------
1 | {
2 | pkgs,
3 | config,
4 | options,
5 | lib,
6 | ...
7 | }:
8 | with lib; let
9 | cfg = config.homeage;
10 |
11 | ageBin = let
12 | binName = (builtins.parseDrvName cfg.pkg.name).name;
13 | in "${cfg.pkg}/bin/${binName}";
14 |
15 | jq = lib.getExe pkgs.jq;
16 |
17 | identities = builtins.concatStringsSep " " (map (path: "-i ${path}") cfg.identityPaths);
18 |
19 | createFiles = command: runtimepath: destinations:
20 | builtins.concatStringsSep "\n" ((map (dest: ''
21 | $DRY_RUN_CMD mkdir $VERBOSE_ARG -p $(dirname ${dest})
22 | $DRY_RUN_CMD ${command} $VERBOSE_ARG ${runtimepath} ${dest}
23 | ''))
24 | destinations);
25 |
26 | statePath = "homeage/state.json";
27 |
28 | decryptSecret = name: {
29 | source,
30 | path,
31 | symlinks,
32 | copies,
33 | mode,
34 | owner,
35 | group,
36 | ...
37 | }: let
38 | linksCmds = createFiles "ln -sf" path symlinks;
39 | copiesCmds = createFiles "cp -f" path copies;
40 | in ''
41 | echo "Decrypting secret ${source} to ${path}"
42 | TMP_FILE="${path}.tmp"
43 | $DRY_RUN_CMD mkdir $VERBOSE_ARG -p $(dirname ${path})
44 | (
45 | $DRY_RUN_CMD umask u=r,g=,o=
46 | $DRY_RUN_CMD ${ageBin} -d ${identities} -o "$TMP_FILE" "${source}"
47 | )
48 | $DRY_RUN_CMD chmod $VERBOSE_ARG ${mode} "$TMP_FILE"
49 | $DRY_RUN_CMD chown $VERBOSE_ARG ${owner}:${group} "$TMP_FILE"
50 | $DRY_RUN_CMD mv $VERBOSE_ARG -f "$TMP_FILE" "${path}"
51 | ${linksCmds}
52 | ${copiesCmds}
53 | '';
54 |
55 | cleanupSecret = prefix: ''
56 | echo "${prefix}Cleaning up decrypted secret: $path"
57 |
58 | # Cleanup symlinks
59 | for symlink in ''${symlinks[@]}; do
60 | if [ ! "$(readlink "$symlink")" == "$path" ]; then
61 | echo "${prefix}Not removing symlink $symlink as it does not point to secret."
62 | continue
63 | fi
64 | echo "${prefix}Removing symlink $symlink..."
65 | unlink "$symlink"
66 | rmdir --ignore-fail-on-non-empty --parents "$(dirname "$symlink")"
67 | done
68 |
69 | # Cleanup copies
70 | for copy in ''${copies[@]}; do
71 | if [ ! -f $path ]; then
72 | echo "${prefix}Not removing copied file $copy because secret does not exist so can't verify wasn't modified."
73 | continue
74 | fi
75 | if ! cmp -s "$copy" "$path"; then
76 | echo "${prefix}Not removing copied file $copy because it was modified."
77 | continue
78 | fi
79 | echo "${prefix}Removing copied file $copy..."
80 | rm "$copy"
81 | rmdir --ignore-fail-on-non-empty --parents "$(dirname "$copy")"
82 | done
83 |
84 | # Cleanup decrypted secret
85 | if [ ! -f "$path" ]; then
86 | echo "${prefix}Not removing secret file $path because does not exist."
87 | continue
88 | else
89 | echo "${prefix}Removing secret file $path..."
90 | rm "$path"
91 | rmdir --ignore-fail-on-non-empty --parents "$(dirname "$path")"
92 | fi
93 | '';
94 |
95 | activationFileCleanup = isActivation: ''
96 | function homeageCleanup() {
97 | # oldGenPath and newGenPath come from activation init:
98 | # https://github.com/nix-community/home-manager/blob/master/modules/lib-bash/activation-init.sh
99 | if [ ! -v oldGenPath ] ; then
100 | echo "[homeage] No previous generation: no cleanup needed."
101 | return 0
102 | fi
103 |
104 | local oldGenFile newGenFile
105 | oldGenFile="$oldGenPath/${statePath}"
106 | ${
107 | lib.optionalString isActivation ''
108 | local newGenFile
109 | newGenFile="$newGenPath/${statePath}"
110 |
111 | # Technically not possible (state always written if has secrets). Check anyway
112 | if [ ! -L "$newGenFile" ]; then
113 | echo "[homeage] Activated but no current state" >&2
114 | return 1
115 | fi
116 | ''
117 | }
118 |
119 | if [ ! -L "$oldGenFile" ]; then
120 | echo "[homeage] No previous homeage state: no cleanup needed"
121 | return 0
122 | fi
123 |
124 | # Get all changed secrets for cleanup (intersection)
125 | ${jq} \
126 | --null-input \
127 | --compact-output \
128 | --argfile old "$oldGenFile" \
129 | ${
130 | if isActivation
131 | then ''
132 | --argfile new "$newGenFile" \
133 | '$old - $new | .[]' |
134 | ''
135 | else ''
136 | '$old | .[]' |
137 | ''
138 | }
139 | # Replace $UID with $(id -u). Don't use eval
140 | ${pkgs.gnused}/bin/sed \
141 | "s/\$UID/$(id -u)/g" |
142 | while IFS=$"\n" read -r c; do
143 | path=$(echo "$c" | ${jq} --raw-output '.path')
144 | symlinks=$(echo "$c" | ${jq} --raw-output '.symlinks[]')
145 | copies=$(echo "$c" | ${jq} --raw-output '.copies[]')
146 |
147 | ${cleanupSecret "[homeage] "}
148 | done
149 | echo "[homeage] Finished cleanup of secrets."
150 | }
151 |
152 | homeageCleanup
153 | '';
154 |
155 | # Options for a secret file
156 | # Based on https://github.com/ryantm/agenix/pull/58
157 | secretFile = types.submodule ({name, ...}: {
158 | options = {
159 | path = mkOption {
160 | description = "Absolute path of where the file will be saved. Defaults to mount/name";
161 | type = types.str;
162 | default = "${cfg.mount}/${name}";
163 | };
164 |
165 | source = mkOption {
166 | description = "Path to the age encrypted file";
167 | type = types.path;
168 | };
169 |
170 | mode = mkOption {
171 | type = types.str;
172 | default = "0400";
173 | description = "Permissions mode of the decrypted file";
174 | };
175 |
176 | owner = mkOption {
177 | type = types.str;
178 | default = "$UID";
179 | description = "User of the decrypted file";
180 | };
181 |
182 | group = mkOption {
183 | type = types.str;
184 | default = "$(id -g)";
185 | description = "Group of the decrypted file";
186 | };
187 |
188 | symlinks = mkOption {
189 | type = types.listOf types.str;
190 | default = [];
191 | description = "Symbolically link decrypted file to absolute paths";
192 | };
193 |
194 | copies = mkOption {
195 | type = types.listOf types.str;
196 | default = [];
197 | description = "Copy decrypted file to absolute paths";
198 | };
199 | };
200 | });
201 | in {
202 | options.homeage = {
203 | file = mkOption {
204 | description = "Attrset of secret files";
205 | default = {};
206 | type = types.attrsOf secretFile;
207 | };
208 |
209 | pkg = mkOption {
210 | description = "(R)age package to use. Detects if using rage and switches to `rage` as the command rather than `age`";
211 | default = pkgs.age;
212 | type = types.package;
213 | };
214 |
215 | mount = mkOption {
216 | description = "Absolute path to folder where decrypted files are stored. Files are decrypted on login. Defaults to /run which is a tmpfs.";
217 | default = "/run/user/$UID/secrets";
218 | type = types.str;
219 | };
220 |
221 | identityPaths = mkOption {
222 | description = "Absolute path to identity files used for age decryption. Must provide at least one path";
223 | default = [];
224 | type = types.listOf types.str;
225 | };
226 |
227 | installationType = mkOption {
228 | description = ''
229 | Specify the way how secrets should be installed. Either via systemd user services (systemd)
230 | or during the activation of the generation (activation).
231 |
232 | Note: Keep in mind that symlinked secrets will not work after reboots with activation if
233 | homeage.mount does not point to persistent location.
234 |
235 | Cleanup notes:
236 | * Systemd performs cleanup when service stops.
237 | * Activation performs cleanup after write boundary during activation.
238 | * When switching from systemd to activation, may need to activate twice.
239 | Because stopping systemd services, and thus cleanup, happens after
240 | activation decryption. Only occurs on the first activation.
241 |
242 | Cases when copied file/symlink is not removed:
243 | 1. Symlink does not point to the decrypted secret file.
244 | 2. Any copied file when the original secret file does not exist (can't verify they weren't modified).
245 | 3. Copied file when it does not match the original secret file (using `cmp`).
246 | '';
247 | default = "systemd";
248 | type = types.enum ["activation" "systemd"];
249 | };
250 | };
251 |
252 | config = mkIf (cfg.file != {}) (mkMerge [
253 | {
254 | assertions = [
255 | {
256 | assertion = cfg.identityPaths != [];
257 | message = "secret.identityPaths must be set.";
258 | }
259 | {
260 | assertion = let
261 | paths = mapAttrsToList (_: value: value.path) cfg.file;
262 | in
263 | (unique paths) == paths;
264 | message = "overlapping secret file paths.";
265 | }
266 | ];
267 |
268 | # Decryption check is enabled for all installation types
269 | home.activation.homeageDecryptCheck = let
270 | decryptCheckScript = name: source: ''
271 | if ! ${ageBin} -d ${identities} -o /dev/null ${source} 2>/dev/null ; then
272 | DECRYPTION="''${DECRYPTION}[homeage] Failed to decrypt ${name}\n"
273 | fi
274 | '';
275 |
276 | checkDecryptionScript = ''
277 | DECRYPTION=
278 | ${
279 | builtins.concatStringsSep "\n"
280 | (lib.mapAttrsToList (n: v: decryptCheckScript n v.source) cfg.file)
281 | }
282 | if [ ! -x $DECRYPTION ]; then
283 | printf "''${errorColor}''${DECRYPTION}[homeage] Check homage.identityPaths to either add an identity or remove a broken one\n''${normalColor}" 1>&2
284 | exit 1
285 | fi
286 | '';
287 | in
288 | hm.dag.entryBefore ["writeBoundary"] checkDecryptionScript;
289 | }
290 | (mkIf (cfg.installationType == "activation") {
291 | home = {
292 | # Always write state if activation installation so will
293 | # cleanup the previous generations when cleanup gets enabled
294 | # Do not write if systemd installation because cleanup will be done through systemd units
295 | extraBuilderCommands = let
296 | stateFile =
297 | pkgs.writeText
298 | "homeage-state.json"
299 | (builtins.toJSON
300 | (map
301 | (secret: secret)
302 | (builtins.attrValues cfg.file)));
303 | in ''
304 | mkdir -p $(dirname $out/${statePath})
305 | ln -s ${stateFile} $out/${statePath}
306 | '';
307 |
308 | activation = {
309 | homeageCleanup = let
310 | fileCleanup = activationFileCleanup true;
311 | in
312 | hm.dag.entryBetween ["homeageDecrypt"] ["writeBoundary"] fileCleanup;
313 |
314 | homeageDecrypt = let
315 | activationScript = builtins.concatStringsSep "\n" (lib.attrsets.mapAttrsToList decryptSecret cfg.file);
316 | in
317 | hm.dag.entryBetween ["reloadSystemd"] ["writeBoundary"] activationScript;
318 | };
319 | };
320 | })
321 | (mkIf (cfg.installationType == "systemd") {
322 | # Need to cleanup secrets if switching from activation -> systemd
323 | home.activation.homeageCleanup = let
324 | fileCleanup = activationFileCleanup false;
325 | in
326 | hm.dag.entryAfter ["writeBoundary"] fileCleanup;
327 |
328 | systemd.user.services = let
329 | mkServices =
330 | lib.attrsets.mapAttrs'
331 | (
332 | name: value:
333 | lib.attrsets.nameValuePair
334 | "${name}-secret"
335 | {
336 | Unit = {
337 | Description = "Decrypt ${name} secret";
338 | };
339 |
340 | Service = {
341 | Type = "oneshot";
342 | Environment = "PATH=${makeBinPath [pkgs.coreutils pkgs.diffutils]}";
343 | ExecStart = "${pkgs.writeShellScript "${name}-decrypt" ''
344 | set -euo pipefail
345 | DRY_RUN_CMD=
346 | VERBOSE_ARG=
347 |
348 | ${decryptSecret name value}
349 | ''}";
350 | RemainAfterExit = true;
351 | ExecStop = "${pkgs.writeShellScript "${name}-cleanup" ''
352 | set -euo pipefail
353 |
354 | path="${value.path}"
355 | symlinks=(${builtins.concatStringsSep " " value.symlinks})
356 | copies=(${builtins.concatStringsSep " " value.copies})
357 |
358 | ${cleanupSecret ""}
359 | ''}";
360 | };
361 |
362 | Install = {
363 | WantedBy = ["default.target"];
364 | };
365 | }
366 | )
367 | cfg.file;
368 | in
369 | mkServices;
370 | })
371 | ]);
372 | }
373 |
--------------------------------------------------------------------------------