diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 6b19f60b1..94fae39fa 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -78,6 +78,13 @@ func main() { cli.VersionPrinter = version.PrintVersion app := cli.NewApp() + // Bridge --age-key-file into its environment variable before any command + // runs, so it applies to every decrypt path (the top-level action as well + // as the decrypt/edit/exec-env/exec-file subcommands, which have their own + // actions). Like other global flags (e.g. --config, --verbose), it must be + // given before the subcommand. + app.Before = ageKeyFileFlagBefore + keyserviceFlags := []cli.Flag{ cli.BoolTFlag{ Name: "enable-local-keyservice", @@ -1734,6 +1741,11 @@ func main() { Usage: "comma separated list of age recipients", EnvVar: "SOPS_AGE_RECIPIENTS", }, + cli.StringFlag{ + Name: "age-key-file", + Usage: "path to a file containing age identities used for decryption", + EnvVar: "SOPS_AGE_KEY_FILE", + }, cli.BoolFlag{ Name: "in-place, i", Usage: "write output back to the same file instead of stdout", @@ -2300,6 +2312,28 @@ func getRotateOpts(c *cli.Context, fileName string, inputStore common.Store, out }, nil } +// ageKeyFileFlagBefore is the app-level Before hook that applies the +// --age-key-file flag. It runs ahead of every command (the implicit top-level +// action and each subcommand), so the flag takes effect on all decrypt paths. +func ageKeyFileFlagBefore(c *cli.Context) error { + return applyAgeKeyFileFlag(c.String("age-key-file")) +} + +// applyAgeKeyFileFlag bridges the --age-key-file CLI flag into the +// SOPS_AGE_KEY_FILE environment variable, which the age keysource reads in +// loadIdentities to locate the identity file used for decryption. Decrypt-time +// age identities are reconstructed from a file's metadata, so there is no +// MasterKey to inject the path into directly; setting the environment the +// keysource already consumes is the least-invasive way to expose this as a +// POSIX-style flag. An explicitly passed flag therefore overrides any +// pre-existing SOPS_AGE_KEY_FILE value. A no-op when the flag is unset. +func applyAgeKeyFileFlag(ageKeyFile string) error { + if ageKeyFile == "" { + return nil + } + return os.Setenv(age.SopsAgeKeyFileEnv, ageKeyFile) +} + func toExitError(err error) error { if cliErr, ok := err.(*cli.ExitError); ok && cliErr != nil { return cliErr diff --git a/cmd/sops/main_test.go b/cmd/sops/main_test.go new file mode 100644 index 000000000..4335724e3 --- /dev/null +++ b/cmd/sops/main_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "flag" + "os" + "testing" + + "github.com/getsops/sops/v3/age" + "github.com/urfave/cli" +) + +func TestApplyAgeKeyFileFlag(t *testing.T) { + t.Run("sets the environment when the flag is provided", func(t *testing.T) { + // t.Setenv registers restoration of the original value; clearing it + // afterwards gives the subtest a known, unset starting point. + t.Setenv(age.SopsAgeKeyFileEnv, "") + if err := os.Unsetenv(age.SopsAgeKeyFileEnv); err != nil { + t.Fatalf("failed to unset %s: %v", age.SopsAgeKeyFileEnv, err) + } + + const want = "/path/to/keys.txt" + if err := applyAgeKeyFileFlag(want); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv(age.SopsAgeKeyFileEnv); got != want { + t.Errorf("%s = %q, want %q", age.SopsAgeKeyFileEnv, got, want) + } + }) + + t.Run("overrides a pre-existing environment value", func(t *testing.T) { + t.Setenv(age.SopsAgeKeyFileEnv, "/from/env") + + const want = "/from/flag" + if err := applyAgeKeyFileFlag(want); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv(age.SopsAgeKeyFileEnv); got != want { + t.Errorf("%s = %q, want %q", age.SopsAgeKeyFileEnv, got, want) + } + }) + + t.Run("leaves the environment untouched when the flag is empty", func(t *testing.T) { + const want = "/from/env" + t.Setenv(age.SopsAgeKeyFileEnv, want) + + if err := applyAgeKeyFileFlag(""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv(age.SopsAgeKeyFileEnv); got != want { + t.Errorf("%s = %q, want %q", age.SopsAgeKeyFileEnv, got, want) + } + }) +} + +// TestAgeKeyFileFlagBefore exercises the actual app.Before hook through a +// cli.Context carrying the --age-key-file flag. Because the hook is wired to +// app.Before (not app.Action), it runs for every command path, including the +// decrypt/exec-env/exec-file subcommands that have their own actions. +func TestAgeKeyFileFlagBefore(t *testing.T) { + t.Setenv(age.SopsAgeKeyFileEnv, "") + if err := os.Unsetenv(age.SopsAgeKeyFileEnv); err != nil { + t.Fatalf("failed to unset %s: %v", age.SopsAgeKeyFileEnv, err) + } + + const want = "/path/to/keys.txt" + set := flag.NewFlagSet("test", flag.ContinueOnError) + set.String("age-key-file", "", "") + if err := set.Set("age-key-file", want); err != nil { + t.Fatalf("failed to set flag: %v", err) + } + + if err := ageKeyFileFlagBefore(cli.NewContext(nil, set, nil)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := os.Getenv(age.SopsAgeKeyFileEnv); got != want { + t.Errorf("%s = %q, want %q", age.SopsAgeKeyFileEnv, got, want) + } +}