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