diff --git a/README.md b/README.md index 85e7857..bff24ad 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ environment variables that can be passed to sops. - Compatible with all NixOS deployment frameworks: [NixOps](https://github.com/NixOS/nixops), nixos-rebuild, [krops](https://github.com/krebs/krops/), [morph](https://github.com/DBCDK/morph), [nixus](https://github.com/Infinisil/nixus), etc. - Version-control friendly: Since all files are encrypted they can be directly committed to version control without worry. Diffs of the secrets are readable, and [can be shown in cleartext](https://github.com/mozilla/sops#showing-diffs-in-cleartext-in-git). - CI friendly: Since sops files can be added to the Nix store without leaking secrets, a machine definition can be built as a whole from a repository, without needing to rely on external secrets or services. +- Home-manager friendly: Provides a home-manager module - Works well in teams: sops-nix comes with `nix-shell` hooks that allows multiple people to quickly import all GPG keys. The cryptography used in sops is designed to be scalable: Secrets are only encrypted once with a master key instead of encrypted per machine/developer key. @@ -34,7 +35,7 @@ There is a `configuration.nix` example in the [deployment step](#deploy-example) ## Supported encryption methods -sops-nix supports two basic ways of encryption, GPG and `age`. +sops-nix supports two basic ways of encryption, GPG and `age`. GPG is based on [GnuPG](https://gnupg.org/) and encrypts against GPG public keys. Private GPG keys may be used to decrypt the secrets on the target machine. The tool [`ssh-to-pgp`](https://github.com/Mic92/ssh-to-pgp) can @@ -49,7 +50,7 @@ format to `age` keys.
1. Install sops-nix -Choose one of the following methods: +Choose one of the following methods. When using it non-globally with home-manager, refer to [Use with home-manager](#use-with-home-manager). #### Flakes (current recommendation) @@ -60,7 +61,7 @@ If you use experimental nix flakes support: inputs.sops-nix.url = github:Mic92/sops-nix; # optional, not necessary for the module #inputs.sops-nix.inputs.nixpkgs.follows = "nixpkgs"; - + outputs = { self, nixpkgs, sops-nix }: { # change `yourhostname` to your actual hostname nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem { @@ -713,7 +714,7 @@ $ head krb5.keytab "mac": "ENC[AES256_GCM,data:ISjUzaw/5mNiwypmUrOk2DAZnlkbnhURHmTTYA3705NmRsSyUh1PyQvCuwglmaHscwl4GrsnIz4rglvwx1zYa+UUwanR0+VeBqntHwzSNiWhh7qMAQwdUXmdCNiOyeGy6jcSDsXUeQmyIWH6yibr7hhzoQFkZEB7Wbvcw6Sossk=,iv:UilxNvfHN6WkEvfY8ZIJCWijSSpLk7fqSCWh6n8+7lk=,tag:HUTgyL01qfVTCNWCTBfqXw==,type:str]", "pgp": [ { - + ``` It can be decrypted again like this: @@ -733,6 +734,63 @@ This is how it can be included in your `configuration.nix`: } ``` +## Use with home manager + +sops-nix also provides a home-manager module. +This module provides a subset of features provided by the system-wide sops-nix since features like the creation of the ramfs and changing the owner of the secrets are not available for non-root users. + +Instead of running as an activation script, sops-nix runs as a systemd user service called `sops-nix.service`. +And instead of decrypting to `/run/secrets`, the secrets are decrypted to `$XDG_RUNTIME_DIR/secrets`. +**Since the secrets are decrypted there, it's highly recommended to use a tmpfs for `$XDG_RUNTIME_DIR` to avoid storing secrets in plain text on persistent storage. Linux distributions using systemd-logind do that out-of-the-box.** + +Depending on whether you use home-manager system-wide or using a home.nix, you have to import it in a different way. +This example show the `channel` approach from the example [Install: nix-channel](#nix-channel) for simplicity, but all other methods work as well. + +```nix +{ + # NixOS system-wide home-manager configuration + home-manager.sharedModules = [ + + ]; +} +``` + +```nix +{ + # Configuration via home.nix + imports = [ + + ]; +} +``` + +The actual sops configuration is in the `sops` namespace in your home.nix (or in the `home-manager.users.` namespace when using home-manager system-wide): +```nix +{ + sops = { + age.keyFile = "/home/user/.age-key.txt"; # must have no password! + # It's alos possible to use a ssh key, but only when it has no password: + #age.sshKeyPaths = [ "/home/user/path-to-ssh-key" ]; + defaultSopsFile = ./secrets.yaml; + secrets.test = { + # sopsFile = ./secrets.yml.enc; # optionally define per-secret files + + # %r gets replaced with a runtime directory, use %% to specify a '%' + # sign. Runtime dir is $XDG_RUNTIME_DIR on linux and $(getconf + # DARWIN_USER_TEMP_DIR) on darwin. + path = "%r/test.txt"; + }; + }; +} +``` + +The secrets are decrypted in a systemd user service called `sops-nix`, so other services needing secrets must order after it: +```nix +{ + systemd.user.services.mbsync.Unit.After = [ "sops-nix.service" ]; +} +``` + ## Use with GPG instead of SSH keys If you prefer having a separate GPG key, sops-nix also comes with a helper tool, `sops-init-gpg-key`: diff --git a/default.nix b/default.nix index 6797d84..8809610 100644 --- a/default.nix +++ b/default.nix @@ -7,6 +7,7 @@ inherit vendorSha256; }; in rec { + inherit sops-install-secrets; sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key {}; sops-pgp-hook = pkgs.lib.warn '' sops-pgp-hook is deprecated, use sops-import-keys-hook instead. @@ -23,8 +24,6 @@ in rec { }; unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {}; } // (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { - inherit sops-install-secrets; - lint = pkgs.callPackage ./pkgs/lint.nix { inherit sops-install-secrets; }; diff --git a/flake.nix b/flake.nix index adc6e0a..7d9c3d1 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,9 @@ inherit (prev) ssh-to-pgp; }; nixosModules.sops = import ./modules/sops; + homeManagerModules.sops = import ./modules/home-manager/sops.nix; nixosModule = self.nixosModules.sops; + homeManagerModule = self.homeManagerModules.sops; packages = forAllSystems (system: import ./default.nix { pkgs = import nixpkgs {inherit system;}; diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix new file mode 100644 index 0000000..addfce4 --- /dev/null +++ b/modules/home-manager/sops.nix @@ -0,0 +1,244 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.sops; + sops-install-secrets = (pkgs.callPackage ../.. {}).sops-install-secrets; + secretType = lib.types.submodule ({ config, name, ... }: { + options = { + name = lib.mkOption { + type = lib.types.str; + default = name; + description = '' + Name of the file used in /run/user/*/secrets + ''; + }; + + key = lib.mkOption { + type = lib.types.str; + default = 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 = "%r/secrets/${name}"; + description = '' + Path where secrets are symlinked to. + If the default is kept no other symlink is created. + `%r` is replaced by $XDG_RUNTIME_DIR on linux or `getconf + DARWIN_USER_TEMP_DIR` on darwin. + ''; + }; + + format = lib.mkOption { + type = lib.types.enum [ "yaml" "json" "binary" "ini" "dotenv" ]; + 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. + ''; + }; + + sopsFile = lib.mkOption { + type = lib.types.path; + default = cfg.defaultSopsFile; + defaultText = "\${config.sops.defaultSopsFile}"; + description = '' + Sops file the secret is loaded from. + ''; + }; + }; + }); + + manifestFor = suffix: secrets: pkgs.writeTextFile { + name = "manifest${suffix}.json"; + text = builtins.toJSON { + secrets = builtins.attrValues secrets; + secretsMountPoint = "%r/secrets.d"; + symlinkPath = "%r/secrets"; + keepGenerations = cfg.keepGenerations; + gnupgHome = cfg.gnupg.home; + sshKeyPaths = cfg.gnupg.sshKeyPaths; + ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; + userMode = true; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; + }; + checkPhase = '' + ${sops-install-secrets}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out" + ''; + }; + + manifest = manifestFor "" cfg.secrets; + + script = toString (pkgs.writeShellScript "sops-nix-user" ((lib.optionalString (cfg.gnupg.home != null) '' + export SOPS_GPG_EXEC=${pkgs.gnupg}/bin/gpg + '') + + (lib.optionalString cfg.age.generateKey '' + if [[ ! -f '${cfg.age.keyFile}' ]]; then + echo generating machine-specific age key... + mkdir -p $(dirname ${cfg.age.keyFile}) + # age-keygen sets 0600 by default, no need to chmod. + ${pkgs.age}/bin/age-keygen -o ${cfg.age.keyFile} + fi + '' + '' + ${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd '${manifest}' + ''))); +in { + options.sops = { + secrets = lib.mkOption { + type = lib.types.attrsOf secretType; + default = {}; + description = '' + Secrets to decrypt. + ''; + }; + + 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"; + }; + + age = { + keyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/home/someuser/.age-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 = []; + 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 = "/home/someuser/.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 = []; + description = '' + Path to ssh keys added as GPG keys during sops description. + This option must be explicitly unset if config.sops.gnupg.sshKeyPaths is set. + ''; + }; + }; + }; + + config = lib.mkIf (cfg.secrets != {}) { + assertions = [{ + assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != []; + message = "No key source configurated for sops"; + } { + 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"; + }]) cfg.secrets) + ); + + systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { + Unit = { + Description = "sops-nix activation"; + }; + Service = { + Type = "oneshot"; + ExecStart = script; + }; + Install.WantedBy = [ "default.target" ]; + }; + + launchd.agents.sops-nix = { + enable = true; + config = { + ProgramArguments = [ script ]; + KeepAlive = { + Crashed = false; + SuccessfulExit = false; + }; + ProcessType = "Background"; + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stdout"; + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stderr"; + }; + }; + }; +} diff --git a/modules/sops/default.nix b/modules/sops/default.nix index 2aac71e..d17fa9a 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -126,6 +126,7 @@ let sshKeyPaths = cfg.gnupg.sshKeyPaths; ageKeyFile = cfg.age.keyFile; ageSshKeyPaths = cfg.age.sshKeyPaths; + userMode = false; logging = { keyImport = builtins.elem "keyImport" cfg.log; secretChanges = builtins.elem "secretChanges" cfg.log; diff --git a/pkgs/sops-install-secrets/darwin.go b/pkgs/sops-install-secrets/darwin.go new file mode 100644 index 0000000..f1c2657 --- /dev/null +++ b/pkgs/sops-install-secrets/darwin.go @@ -0,0 +1,110 @@ +//go:build darwin +// +build darwin + +package main + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "strings" + + "golang.org/x/sys/unix" +) + +func RuntimeDir() (string, error) { + // TODO this could be garbage collected on a 3d basis + out, err := exec.Command("getconf", "DARWIN_USER_TEMP_DIR").Output() + rundir := strings.TrimRight(string(out[:]), " \t\n") + if err != nil { + return "", fmt.Errorf("Cannot get DARWIN_USER_TEMP_DIR: %v", err) + } + return rundir, nil +} + +func SecureSymlinkChown(symlinkToCheck string, expectedTarget string, owner, group int) error { + // not sure what O_PATH is needed for anyways + fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_SYMLINK|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err) + } + defer unix.Close(fd) + + buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc + n, err := unix.Readlinkat(fd, "", buf) + if err != nil { + return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck) + } + if n > len(expectedTarget) || string(buf[:n]) != expectedTarget { + return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget) + } + err = unix.Fchownat(fd, "", owner, group, unix.AT_SYMLINK_NOFOLLOW) + if err != nil { + return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err) + } + return nil +} + +// Does: +// mkdir /tmp/mymount +// NUMSECTORS=128000 # a sector is 512 bytes +// mydev=`hdiutil attach -nomount ram://$NUMSECTORS` +// newfs_hfs $mydev +// mount -t hfs $mydev /tmp/mymount +func MountSecretFs(mountpoint string, keysGid int, userMode bool) error { + if err := os.MkdirAll(mountpoint, 0751); err != nil { + return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err) + } + if _, err := os.Stat(mountpoint + "/sops-nix-secretfs"); !errors.Is(err, os.ErrNotExist) { + return nil // secret fs already exists + } + + // MacOS/darwin options for temporary files: + // - /tmp or NSTemporaryDirectory is persistent, and regularly wiped from files not touched >3d + // https://wiki.lazarus.freepascal.org/Locating_the_macOS_tmp_directory + // - there is no ramfs, also `man statfs` doesn't have flags for memfs things + // - we can allocate and mount statically allocated memory (ram://), however most + // functions for that are not publicly exposed to userspace. + mb := 64 // size in MB + 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", 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", 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. + _, err = os.Create(mountpoint + "/sops-nix-secretfs") + + // This would be the way to check on unix. + //buf := unix.Statfs_t{} + //if err := unix.Statfs(mountpoint, &buf); err != nil { + // return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err) + //} + // + //if int32(buf.Type) != RAMFS_MAGIC { + // if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil { + // return fmt.Errorf("Cannot mount: %s", err) + // } + //} + + if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil { + return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err) + } + + return nil +} diff --git a/pkgs/sops-install-secrets/default.nix b/pkgs/sops-install-secrets/default.nix index 579d52a..358f931 100644 --- a/pkgs/sops-install-secrets/default.nix +++ b/pkgs/sops-install-secrets/default.nix @@ -15,10 +15,13 @@ buildGoModule { inherit pkgs; }; - outputs = [ "out" "unittest" ]; + outputs = [ "out" ] ++ + pkgs.lib.lists.optionals (pkgs.stdenv.isLinux) [ "unittest" ]; postInstall = '' go test -c ./pkgs/sops-install-secrets + '' + pkgs.lib.optionalString (pkgs.stdenv.isLinux) '' + # *.test is only tested on linux. $unittest does not exist on darwin. install -D ./sops-install-secrets.test $unittest/bin/sops-install-secrets.test # newer versions of nixpkgs no longer require this step if command -v remove-references-to; then @@ -33,6 +36,6 @@ buildGoModule { homepage = "https://github.com/Mic92/sops-nix"; license = licenses.mit; maintainers = with maintainers; [ mic92 ]; - platforms = platforms.linux; + platforms = platforms.linux ++ platforms.darwin; }; } diff --git a/pkgs/sops-install-secrets/linux.go b/pkgs/sops-install-secrets/linux.go new file mode 100644 index 0000000..35c760a --- /dev/null +++ b/pkgs/sops-install-secrets/linux.go @@ -0,0 +1,69 @@ +//go:build linux +// +build linux + +package main + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func RuntimeDir() (string, error) { + rundir, ok := os.LookupEnv("XDG_RUNTIME_DIR") + if !ok { + return "", fmt.Errorf("$XDG_RUNTIME_DIR is not set!") + } + return rundir, nil +} + +func SecureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) error { + // fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0) + fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err) + } + defer unix.Close(fd) + + buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc + n, err := unix.Readlinkat(fd, "", buf) + if err != nil { + return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck) + } + if n > len(expectedTarget) || string(buf[:n]) != expectedTarget { + return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget) + } + err = unix.Fchownat(fd, "", owner, group, unix.AT_EMPTY_PATH) + if err != nil { + return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err) + } + return nil +} + +func MountSecretFs(mountpoint string, keysGid int, userMode bool) error { + if err := os.MkdirAll(mountpoint, 0751); err != nil { + return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err) + } + + // We can't create a ramfs as user + if userMode { + return nil + } + + buf := unix.Statfs_t{} + if err := unix.Statfs(mountpoint, &buf); err != nil { + return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err) + } + if int32(buf.Type) != RAMFS_MAGIC { + if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil { + return fmt.Errorf("Cannot mount: %s", err) + } + } + + if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil { + return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err) + } + + return nil +} diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index 12d5609..015c1ea 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -1,5 +1,3 @@ -// +build linux - package main import ( @@ -23,7 +21,6 @@ import ( "github.com/mozilla-services/yaml" "go.mozilla.org/sops/v3/decrypt" - "golang.org/x/sys/unix" "github.com/joho/godotenv" ) @@ -58,6 +55,7 @@ type manifest struct { GnupgHome string `json:"gnupgHome"` AgeKeyFile string `json:"ageKeyFile"` AgeSshKeyPaths []string `json:"ageSshKeyPaths"` + UserMode bool `json:"userMode"` Logging loggingConfig `json:"logging"` } @@ -132,28 +130,6 @@ type appContext struct { ignorePasswd bool } -func secureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) error { - fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0) - if err != nil { - return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err) - } - defer unix.Close(fd) - - buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc - n, err := unix.Readlinkat(fd, "", buf) - if err != nil { - return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck) - } - if n > len(expectedTarget) || string(buf[:n]) != expectedTarget { - return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget) - } - err = unix.Fchownat(fd, "", owner, group, unix.AT_EMPTY_PATH) - if err != nil { - return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err) - } - return nil -} - func readManifest(path string) (*manifest, error) { file, err := os.Open(path) if err != nil { @@ -179,15 +155,17 @@ func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, secret *secr return linkTarget == targetFile && validUG } -func symlinkSecret(targetFile string, secret *secret) error { +func symlinkSecret(targetFile string, secret *secret, userMode bool) error { for { stat, err := os.Lstat(secret.Path) if os.IsNotExist(err) { if err := os.Symlink(targetFile, secret.Path); err != nil { return fmt.Errorf("Cannot create symlink '%s': %w", secret.Path, err) } - if err := secureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil { - return fmt.Errorf("Cannot chown symlink '%s': %w", secret.Path, err) + if !userMode { + if err := SecureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil { + return fmt.Errorf("Cannot chown symlink '%s': %w", secret.Path, err) + } } return nil } else if err != nil { @@ -209,7 +187,7 @@ func symlinkSecret(targetFile string, secret *secret) error { } } -func symlinkSecrets(targetDir string, secrets []secret) error { +func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error { for _, secret := range secrets { targetFile := filepath.Join(targetDir, secret.Name) if targetFile == secret.Path { @@ -219,7 +197,7 @@ func symlinkSecrets(targetDir string, secrets []secret) error { if err := os.MkdirAll(parent, os.ModePerm); err != nil { return fmt.Errorf("Cannot create parent directory of '%s': %w", secret.Path, err) } - if err := symlinkSecret(targetFile, &secret); err != nil { + if err := symlinkSecret(targetFile, &secret, userMode); err != nil { return fmt.Errorf("Failed to symlink secret '%s': %w", secret.Path, err) } } @@ -328,29 +306,7 @@ func decryptSecrets(secrets []secret) error { const RAMFS_MAGIC int32 = -2054924042 -func mountSecretFs(mountpoint string, keysGid int) error { - if err := os.MkdirAll(mountpoint, 0751); err != nil { - return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err) - } - - buf := unix.Statfs_t{} - if err := unix.Statfs(mountpoint, &buf); err != nil { - return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err) - } - if int32(buf.Type) != RAMFS_MAGIC { - if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil { - return fmt.Errorf("Cannot mount: %s", err) - } - } - - if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil { - return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err) - } - - return nil -} - -func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int) (*string, error) { +func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int, userMode bool) (*string, error) { var generation uint64 linkTarget, err := os.Readlink(linkName) if err == nil { @@ -374,13 +330,15 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int) (* if err := os.Mkdir(dir, os.FileMode(0751)); err != nil { return nil, fmt.Errorf("mkdir(): %w", err) } - if err := os.Chown(dir, 0, int(keysGid)); err != nil { - return nil, fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", dir, keysGid, err) + if !userMode { + if err := os.Chown(dir, 0, int(keysGid)); err != nil { + return nil, fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", dir, keysGid, err) + } } return &dir, nil } -func writeSecrets(secretDir string, secrets []secret, keysGid int) error { +func writeSecrets(secretDir string, secrets []secret, keysGid int, userMode bool) error { for _, secret := range secrets { fp := filepath.Join(secretDir, secret.Name) @@ -391,23 +349,27 @@ func writeSecrets(secretDir string, secrets []secret, keysGid int) error { if err := os.MkdirAll(pathSoFar, 0751); err != nil { return fmt.Errorf("Cannot create directory '%s' for %s: %w", pathSoFar, fp, err) } - if err := os.Chown(pathSoFar, 0, int(keysGid)); err != nil { - return fmt.Errorf("Cannot own directory '%s' for %s: %w", pathSoFar, fp, err) + if !userMode { + if err := os.Chown(pathSoFar, 0, int(keysGid)); err != nil { + return fmt.Errorf("Cannot own directory '%s' for %s: %w", pathSoFar, fp, err) + } } } if err := ioutil.WriteFile(fp, []byte(secret.value), secret.mode); err != nil { return fmt.Errorf("Cannot write %s: %w", fp, err) } - if err := os.Chown(fp, secret.owner, secret.group); err != nil { - return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err) + if !userMode { + if err := os.Chown(fp, secret.owner, secret.group); err != nil { + return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err) + } } } return nil } -func lookupKeysGroup() (int, error) { - group, err := user.LookupGroup("keys") +func lookupGroup(groupname string) (int, error) { + group, err := user.LookupGroup(groupname) if err != nil { return 0, fmt.Errorf("Failed to lookup 'keys' group: %w", err) } @@ -418,6 +380,18 @@ func lookupKeysGroup() (int, error) { return int(gid), nil } +func lookupKeysGroup() (int, error) { + gid, err1 := lookupGroup("keys") + if err1 == nil { + return gid, nil + } + gid, err2 := lookupGroup("nogroup") + if err2 == nil { + return gid, nil + } + return 0, fmt.Errorf("Can't find group 'keys' nor 'nogroup' (%w).", err2) +} + func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) { if app.checkMode == Manifest { return &secretFile{firstSecret: s}, nil @@ -488,7 +462,7 @@ func (app *appContext) validateSecret(secret *secret) error { if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" { secret.owner = 0 secret.group = 0 - } else if app.checkMode == Off { + } else if app.checkMode == Off || app.ignorePasswd { // we only access to the user/group during deployment owner, err := user.Lookup(secret.Owner) if err != nil { @@ -873,7 +847,7 @@ func parseFlags(args []string) (*options, error) { } var checkMode string fs.StringVar(&checkMode, "check-mode", "off", `Validate configuration without installing it (possible values: "manifest","sopsfile","off")`) - fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root`) + fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root or the user executing the tool in user mode`) if err := fs.Parse(args[1:]); err != nil { return nil, err } @@ -893,6 +867,19 @@ func parseFlags(args []string) (*options, error) { return &opts, nil } +func replaceRuntimeDir(path, rundir string) (ret string) { + parts := strings.Split(path, "%%") + first := true + for _, part := range parts { + if !first { + ret += "%" + } + first = false + ret += strings.ReplaceAll(part, "%r", rundir) + } + return +} + func installSecrets(args []string) error { opts, err := parseFlags(args) if err != nil { @@ -904,6 +891,21 @@ func installSecrets(args []string) error { return err } + if manifest.UserMode { + rundir, err := RuntimeDir() + if opts.checkMode == Off && err != nil { + return fmt.Errorf("Error: %v", err) + } + manifest.SecretsMountPoint = replaceRuntimeDir(manifest.SecretsMountPoint, rundir) + manifest.SymlinkPath = replaceRuntimeDir(manifest.SymlinkPath, rundir) + var newSecrets []secret + for _, secret := range manifest.Secrets { + secret.Path = replaceRuntimeDir(secret.Path, rundir) + newSecrets = append(newSecrets, secret) + } + manifest.Secrets = newSecrets + } + app := appContext{ manifest: *manifest, checkMode: opts.checkMode, @@ -931,7 +933,7 @@ func installSecrets(args []string) error { isDry := os.Getenv("NIXOS_ACTION") == "dry-activate" - if err := mountSecretFs(manifest.SecretsMountPoint, keysGid); err != nil { + if err := MountSecretFs(manifest.SecretsMountPoint, keysGid, manifest.UserMode); err != nil { return fmt.Errorf("Failed to mount filesystem for secrets: %w", err) } @@ -983,21 +985,23 @@ func installSecrets(args []string) error { return err } - secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGid) + secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGid, manifest.UserMode) if err != nil { return fmt.Errorf("Failed to prepare new secrets directory: %w", err) } - if err := writeSecrets(*secretDir, manifest.Secrets, keysGid); err != nil { + if err := writeSecrets(*secretDir, manifest.Secrets, keysGid, manifest.UserMode); err != nil { return fmt.Errorf("Cannot write secrets: %w", err) } - if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil { - return fmt.Errorf("Cannot request units to restart: %w", err) + if !manifest.UserMode { + if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil { + return fmt.Errorf("Cannot request units to restart: %w", err) + } } // No need to perform the actual symlinking if isDry { return nil } - if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets); err != nil { + if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets, manifest.UserMode); err != nil { return fmt.Errorf("Failed to prepare symlinks to secret store: %w", err) } if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil { diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go index 164de79..e9fcc92 100644 --- a/pkgs/sops-install-secrets/main_test.go +++ b/pkgs/sops-install-secrets/main_test.go @@ -1,4 +1,5 @@ -// +build linux +//go:build linux || darwin +// +build linux darwin package main