From d2bd7f433b28db6bc7ae03d5eca43564da0af054 Mon Sep 17 00:00:00 2001 From: Ian Date: Sun, 3 Nov 2024 19:51:58 +0000 Subject: [PATCH] Implement darwin module for sops-nix --- flake.nix | 4 + modules/nix-darwin/default.nix | 339 ++++++++++++++++++ modules/nix-darwin/manifest-for.nix | 29 ++ .../nix-darwin/secrets-for-users/default.nix | 42 +++ modules/nix-darwin/templates/default.nix | 87 +++++ modules/nix-darwin/with-environment.nix | 13 + pkgs/sops-install-secrets/darwin.go | 6 - 7 files changed, 514 insertions(+), 6 deletions(-) create mode 100644 modules/nix-darwin/default.nix create mode 100644 modules/nix-darwin/manifest-for.nix create mode 100644 modules/nix-darwin/secrets-for-users/default.nix create mode 100644 modules/nix-darwin/templates/default.nix create mode 100644 modules/nix-darwin/with-environment.nix diff --git a/flake.nix b/flake.nix index f3fa146..8ebb37d 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,10 @@ }; homeManagerModules.sops = ./modules/home-manager/sops.nix; homeManagerModule = self.homeManagerModules.sops; + darwinModules = { + sops = ./modules/nix-darwin; + default = self.darwinModules.sops; + }; packages = forAllSystems (system: import ./default.nix { pkgs = import nixpkgs {inherit system;}; diff --git a/modules/nix-darwin/default.nix b/modules/nix-darwin/default.nix new file mode 100644 index 0000000..50dec02 --- /dev/null +++ b/modules/nix-darwin/default.nix @@ -0,0 +1,339 @@ +{ config, options, lib, pkgs, ... }: + +let + cfg = config.sops; + sops-install-secrets = cfg.package; + manifestFor = pkgs.callPackage ./manifest-for.nix { + inherit cfg; + inherit (pkgs) writeTextFile; + }; + manifest = manifestFor "" regularSecrets {}; + + pathNotInStore = lib.mkOptionType { + name = "pathNotInStore"; + description = "path not in the Nix store"; + descriptionClass = "noun"; + check = x: !lib.path.hasStorePathPrefix (/. + x); + merge = lib.mergeEqualOption; + }; + + regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets; + + withEnvironment = import ./with-environment.nix { + inherit cfg lib; + }; + secretType = lib.types.submodule ({ config, ... }: { + config = { + sopsFile = lib.mkOptionDefault cfg.defaultSopsFile; + sopsFileHash = lib.mkOptionDefault (lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}"); + }; + options = { + name = lib.mkOption { + type = lib.types.str; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets + ''; + }; + key = lib.mkOption { + type = lib.types.str; + default = config._module.args.name; + description = '' + Key used to lookup in the sops file. + No tested data structures are supported right now. + This option is ignored if format is binary. + ''; + }; + path = lib.mkOption { + type = lib.types.str; + default = if config.neededForUsers then "/run/secrets-for-users/${config.name}" else "/run/secrets/${config.name}"; + defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise."; + description = '' + Path where secrets are symlinked to. + If the default is kept no symlink is created. + ''; + }; + format = lib.mkOption { + type = lib.types.enum ["yaml" "json" "binary" "dotenv" "ini"]; + default = cfg.defaultSopsFormat; + description = '' + File format used to decrypt the sops secret. + Binary files are written to the target file as is. + ''; + }; + mode = lib.mkOption { + type = lib.types.str; + default = "0400"; + description = '' + Permissions mode of the in octal. + ''; + }; + owner = lib.mkOption { + type = with lib.types; nullOr str; + default = "root"; + description = '' + User of the file. Can only be set if uid is 0. + ''; + }; + uid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist. + ''; + }; + group = lib.mkOption { + type = with lib.types; nullOr str; + default = "staff"; + defaultText = "staff"; + description = '' + Group of the file. Can only be set if gid is 0. + ''; + }; + gid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist. + ''; + }; + sopsFile = lib.mkOption { + type = lib.types.path; + defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}"; + description = '' + Sops file the secret is loaded from. + ''; + }; + sopsFileHash = lib.mkOption { + type = lib.types.str; + readOnly = true; + description = '' + Hash of the sops file. + ''; + }; + neededForUsers = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + **Warning** This option doesn't have any effect on macOS, as nix-darwin cannot manage user passwords on macOS. + This can be used to retrieve user's passwords from sops-nix. + Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root. + ''; + }; + }; + }); + + darwinSSHKeys = [{ + type = "rsa"; + path = "/etc/ssh/ssh_host_rsa_key"; + } { + type = "ed25519"; + path = "/etc/ssh/ssh_host_ed25519_key"; + }]; + + escapedKeyFile = lib.escapeShellArg cfg.age.keyFile; + # Skip ssh keys deployed with sops to avoid a catch 22 + defaultImportKeys = algo: + map (e: e.path) (lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) darwinSSHKeys); + + installScript = '' + ${if cfg.age.generateKey then '' + if [[ ! -f ${escapedKeyFile} ]]; then + echo generating machine-specific age key... + mkdir -p $(dirname ${escapedKeyFile}) + # age-keygen sets 0600 by default, no need to chmod. + ${pkgs.age}/bin/age-keygen -o ${escapedKeyFile} + fi + '' else ""} + echo "Setting up secrets..." + ${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"} + ''; + +in { + options.sops = { + secrets = lib.mkOption { + type = lib.types.attrsOf secretType; + default = {}; + description = '' + Path where the latest secrets are mounted to. + ''; + }; + + defaultSopsFile = lib.mkOption { + type = lib.types.path; + description = '' + Default sops file used for all secrets. + ''; + }; + + defaultSopsFormat = lib.mkOption { + type = lib.types.str; + default = "yaml"; + description = '' + Default sops format used for all secrets. + ''; + }; + + validateSopsFiles = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Check all sops files at evaluation time. + This requires sops files to be added to the nix store. + ''; + }; + + keepGenerations = lib.mkOption { + type = lib.types.ints.unsigned; + default = 1; + description = '' + Number of secrets generations to keep. Setting this to 0 disables pruning. + ''; + }; + + log = lib.mkOption { + type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]); + default = [ "keyImport" "secretChanges" ]; + description = "What to log"; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path); + default = {}; + description = '' + Environment variables to set before calling sops-install-secrets. + + The values are placed in single quotes and not escaped any further to + allow usage of command substitutions for more flexibility. To properly quote + strings with quotes use lib.escapeShellArg. + + This will be evaluated twice when using secrets that use neededForUsers but + in a subshell each time so the environment variables don't collide. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + default = (pkgs.callPackage ../.. {}).sops-install-secrets; + defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets"; + description = '' + sops-install-secrets package to use. + ''; + }; + + validationPackage = lib.mkOption { + type = lib.types.package; + default = + if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform + then sops-install-secrets + else (pkgs.pkgsBuildHost.callPackage ../.. {}).sops-install-secrets; + defaultText = lib.literalExpression "config.sops.package"; + + description = '' + sops-install-secrets package to use when validating configuration. + + Defaults to sops.package if building natively, and a native version of sops-install-secrets if cross compiling. + ''; + }; + + age = { + keyFile = lib.mkOption { + type = lib.types.nullOr pathNotInStore; + default = null; + example = "/var/lib/sops-nix/key.txt"; + description = '' + Path to age key file used for sops decryption. + ''; + }; + + generateKey = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether or not to generate the age key. If this + option is set to false, the key must already be + present at the specified location. + ''; + }; + + sshKeyPaths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = defaultImportKeys "ed25519"; + defaultText = lib.literalMD "The ed25519 keys from {option}`config.services.openssh.hostKeys`"; + description = '' + Paths to ssh keys added as age keys during sops description. + ''; + }; + }; + + gnupg = { + home = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/root/.gnupg"; + description = '' + Path to gnupg database directory containing the key for decrypting the sops file. + ''; + }; + + sshKeyPaths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = defaultImportKeys "rsa"; + defaultText = lib.literalMD "The rsa keys from {option}`config.services.openssh.hostKeys`"; + description = '' + Path to ssh keys added as GPG keys during sops description. + This option must be explicitly unset if config.sops.gnupg.home is set. + ''; + }; + }; + }; + imports = [ + ./templates + ./secrets-for-users + ]; + + config = lib.mkMerge [ + (lib.mkIf (cfg.secrets != {}) { + assertions = [{ + assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != []; + message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home"; + } { + assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []); + message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set"; + }] ++ lib.optionals cfg.validateSopsFiles ( + lib.concatLists (lib.mapAttrsToList (name: secret: [{ + assertion = builtins.pathExists secret.sopsFile; + message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; + } { + assertion = + builtins.isPath secret.sopsFile || + (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); + message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; + } { + assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null; + message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set"; + } { + assertion = secret.gid != null && secret.gid != 0 -> secret.group == null; + message = "In ${secret.name} exactly one of sops.group and sops.gid must be set"; + }]) cfg.secrets) + ); + + system.build.sops-nix-manifest = manifest; + system.activationScripts = { + postActivation.text = lib.mkAfter installScript; + }; + + launchd.daemons.sops-install-secrets = { + command = installScript; + serviceConfig = { + RunAtLoad = true; + KeepAlive = false; + }; + }; + }) + + { + sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != []) (lib.mkDefault "${pkgs.gnupg}/bin/gpg"); + } + ]; +} diff --git a/modules/nix-darwin/manifest-for.nix b/modules/nix-darwin/manifest-for.nix new file mode 100644 index 0000000..6ab2ba0 --- /dev/null +++ b/modules/nix-darwin/manifest-for.nix @@ -0,0 +1,29 @@ +{ writeTextFile, cfg }: + +suffix: secrets: extraJson: + +writeTextFile { + name = "manifest${suffix}.json"; + text = builtins.toJSON ({ + secrets = builtins.attrValues secrets; + # Does this need to be configurable? + secretsMountPoint = "/run/secrets.d"; + symlinkPath = "/run/secrets"; + keepGenerations = cfg.keepGenerations; + gnupgHome = cfg.gnupg.home; + sshKeyPaths = cfg.gnupg.sshKeyPaths; + ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; + useTmpfs = false; + templates = cfg.templates; + placeholderBySecretName = cfg.placeholder; + userMode = false; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; + } // extraJson); + checkPhase = '' + ${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out" + ''; +} diff --git a/modules/nix-darwin/secrets-for-users/default.nix b/modules/nix-darwin/secrets-for-users/default.nix new file mode 100644 index 0000000..b2c830a --- /dev/null +++ b/modules/nix-darwin/secrets-for-users/default.nix @@ -0,0 +1,42 @@ +{ lib, options, config, pkgs, ... }: +let + cfg = config.sops; + secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; + manifestFor = pkgs.callPackage ../manifest-for.nix { + inherit cfg; + inherit (pkgs) writeTextFile; + }; + withEnvironment = import ../with-environment.nix { + inherit cfg lib; + }; + manifestForUsers = manifestFor "-for-users" secretsForUsers { + secretsMountPoint = "/run/secrets-for-users.d"; + symlinkPath = "/run/secrets-for-users"; + }; + + installScript = '' + echo "Setting up secrets for users" + ${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"} + ''; +in +{ + + assertions = [{ + assertion = (lib.filterAttrs (_: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root")) secretsForUsers) == { }; + message = "neededForUsers cannot be used for secrets that are not root-owned"; + }]; + + system.activationScripts = lib.mkIf (secretsForUsers != []) { + postActivation.text = lib.mkAfter installScript; + }; + + launchd.daemons.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != []) { + command = installScript; + serviceConfig = { + RunAtLoad = true; + KeepAlive = false; + }; + }; + + system.build.sops-nix-users-manifest = manifestForUsers; +} diff --git a/modules/nix-darwin/templates/default.nix b/modules/nix-darwin/templates/default.nix new file mode 100644 index 0000000..2bb1e43 --- /dev/null +++ b/modules/nix-darwin/templates/default.nix @@ -0,0 +1,87 @@ +{ config, pkgs, lib, options, ... }: +let + inherit (lib) + mkOption + mkDefault + mapAttrs + types + ; +in { + options.sops = { + templates = mkOption { + description = "Templates for secret files"; + type = types.attrsOf (types.submodule ({ config, ... }: { + options = { + name = mkOption { + type = types.singleLineStr; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets/rendered + ''; + }; + path = mkOption { + description = "Path where the rendered file will be placed"; + type = types.singleLineStr; + default = "/run/secrets/rendered/${config.name}"; + }; + content = mkOption { + type = types.lines; + default = ""; + description = '' + Content of the file + ''; + }; + mode = mkOption { + type = types.singleLineStr; + default = "0400"; + description = '' + Permissions mode of the rendered secret file in octal. + ''; + }; + owner = mkOption { + type = types.singleLineStr; + default = "root"; + description = '' + User of the file. + ''; + }; + group = mkOption { + type = types.singleLineStr; + default = "staff"; + defaultText = "staff"; + description = '' + Group of the file. Default on darwin in staff. + ''; + }; + file = mkOption { + type = types.path; + default = pkgs.writeText config.name config.content; + defaultText = lib.literalExpression ''pkgs.writeText config.name config.content''; + example = "./configuration-template.conf"; + description = '' + File used as the template. When this value is specified, `sops.templates..content` is ignored. + ''; + }; + }; + })); + default = { }; + }; + placeholder = mkOption { + type = types.attrsOf (types.mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = lib.strings.isConvertibleWithToString; + merge = lib.mergeEqualOption; + }); + default = { }; + visible = false; + }; + }; + + config = lib.optionalAttrs (options ? sops.secrets) + (lib.mkIf (config.sops.templates != { }) { + sops.placeholder = mapAttrs + (name: _: mkDefault "") + config.sops.secrets; + }); +} diff --git a/modules/nix-darwin/with-environment.nix b/modules/nix-darwin/with-environment.nix new file mode 100644 index 0000000..f252441 --- /dev/null +++ b/modules/nix-darwin/with-environment.nix @@ -0,0 +1,13 @@ +{ cfg, lib }: + +sopsCall: + +if cfg.environment == {} then + sopsCall +else '' + ( + # shellcheck disable=SC2030,SC2031 + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)} + ${sopsCall} + ) +'' diff --git a/pkgs/sops-install-secrets/darwin.go b/pkgs/sops-install-secrets/darwin.go index b56064c..05cbf75 100644 --- a/pkgs/sops-install-secrets/darwin.go +++ b/pkgs/sops-install-secrets/darwin.go @@ -6,7 +6,6 @@ package main import ( "errors" "fmt" - "log" "os" "os/exec" "strings" @@ -71,21 +70,16 @@ func MountSecretFs(mountpoint string, keysGID int, _useTmpfs bool, userMode bool size := mb * 1024 * 1024 / 512 // size in sectors a 512 bytes cmd := exec.Command("hdiutil", "attach", "-nomount", fmt.Sprintf("ram://%d", int(size))) out, err := cmd.Output() // /dev/diskN - log.Printf("%q\n", string(out)) diskpath := strings.TrimRight(string(out[:]), " \t\n") - log.Printf("%q\n", diskpath) - log.Printf("hdiutil attach ret %v. out: %s", err, diskpath) // format as hfs out, err = exec.Command("newfs_hfs", "-s", diskpath).Output() - log.Printf("newfs_hfs ret %v. out: %s", err, out) // "posix" mount takes `struct hfs_mount_args` which we dont have bindings for at hand. // See https://stackoverflow.com/a/49048846/4108673 // err = unix.Mount("hfs", mountpoint, unix.MNT_NOEXEC|unix.MNT_NODEV, mount_args) // Instead we call: out, err = exec.Command("mount", "-t", "hfs", "-o", "nobrowse,nodev,nosuid,-m=0751", diskpath, mountpoint).Output() - log.Printf("mount ret %v. out: %s", err, out) // There is no documented way to check for memfs mountpoint. Thus we place a file. path := mountpoint + "/sops-nix-secretfs"