diff --git a/internal/decryptor/decryptor.go b/internal/decryptor/decryptor.go index 70b61375..bec4cb54 100644 --- a/internal/decryptor/decryptor.go +++ b/internal/decryptor/decryptor.go @@ -40,6 +40,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/kustomize/api/konfig" "sigs.k8s.io/kustomize/api/resource" @@ -194,13 +195,26 @@ func IsEncryptedSecret(object *unstructured.Unstructured) bool { // For the import of PGP keys, the Decryptor must be configured with // an absolute GnuPG home directory path. func (d *Decryptor) ImportKeys(ctx context.Context) error { - if d.kustomization.Spec.Decryption == nil || d.kustomization.Spec.Decryption.SecretRef == nil { + if d.kustomization.Spec.Decryption == nil { return nil } provider := d.kustomization.Spec.Decryption.Provider switch provider { case DecryptionProviderSOPS: + // load age key from env variable + globalAgeIdentities, err := age.GlobalIdentities() + if err != nil { + log := ctrl.LoggerFrom(ctx) + log.Info("failed to decrypt age identity from environment, ignoring", "error", err) + } else { + d.ageIdentities = append(d.ageIdentities, globalAgeIdentities...) + } + + if d.kustomization.Spec.Decryption.SecretRef == nil { + return nil + } + secretName := types.NamespacedName{ Namespace: d.kustomization.GetNamespace(), Name: d.kustomization.Spec.Decryption.SecretRef.Name, @@ -214,7 +228,6 @@ func (d *Decryptor) ImportKeys(ctx context.Context) error { return fmt.Errorf("cannot get %s decryption Secret '%s': %w", provider, secretName, err) } - var err error for name, value := range secret.Data { switch filepath.Ext(name) { case DecryptionPGPExt: diff --git a/internal/decryptor/decryptor_test.go b/internal/decryptor/decryptor_test.go index 322b856a..ad12fddb 100644 --- a/internal/decryptor/decryptor_test.go +++ b/internal/decryptor/decryptor_test.go @@ -88,6 +88,7 @@ func TestDecryptor_ImportKeys(t *testing.T) { secret *corev1.Secret wantErr bool inspectFunc func(g *GomegaWithT, decryptor *Decryptor) + env map[string]string }{ { name: "PGP key", @@ -169,6 +170,54 @@ func TestDecryptor_ImportKeys(t *testing.T) { g.Expect(decryptor.ageIdentities).To(HaveLen(0)) }, }, + { + name: "age key from env", + decryption: &kustomizev1.Decryption{ + Provider: provider, + }, + env: map[string]string{ + "FLUX_SOPS_AGE_KEY": string(ageKey), + }, + inspectFunc: func(g *GomegaWithT, decryptor *Decryptor) { + g.Expect(decryptor.ageIdentities).To(HaveLen(1)) + }, + }, + { + name: "age key from env invalid", + decryption: &kustomizev1.Decryption{ + Provider: provider, + }, + env: map[string]string{ + "FLUX_SOPS_AGE_KEY": "invalid-key", + }, + inspectFunc: func(g *GomegaWithT, decryptor *Decryptor) { + g.Expect(decryptor.ageIdentities).To(HaveLen(0)) + }, + }, + { + name: "age key from env and secret", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "age-secret", + }, + }, + env: map[string]string{ + "FLUX_SOPS_AGE_KEY": string(ageKey), + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "age-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "age" + DecryptionAgeExt: ageKey, + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *Decryptor) { + g.Expect(decryptor.ageIdentities).To(HaveLen(2)) + }, + }, { name: "HC Vault token", decryption: &kustomizev1.Decryption{ @@ -376,6 +425,19 @@ clientSecret: some-client-secret`), }, } + for envName, envVal := range tt.env { + cleanName := envName + prevValue, wasPresent := os.LookupEnv(envName) + t.Cleanup(func() { + if wasPresent { + os.Setenv(cleanName, prevValue) + } else { + os.Unsetenv(cleanName) + } + }) + os.Setenv(envName, envVal) + } + d, cleanup, err := NewTempDecryptor("", cb.Build(), &kustomization) g.Expect(err).ToNot(HaveOccurred()) t.Cleanup(cleanup) diff --git a/internal/sops/age/keysource.go b/internal/sops/age/keysource.go index c63d3686..6ad13ff5 100644 --- a/internal/sops/age/keysource.go +++ b/internal/sops/age/keysource.go @@ -11,12 +11,32 @@ import ( "bytes" "fmt" "io" + "os" "strings" "filippo.io/age" "filippo.io/age/armor" ) +const ( + // SopsAgeKeyEnv can be set as an environment variable to provide + // an additional key to use for decryption. + SopsAgeKeyEnv = "FLUX_SOPS_AGE_KEY" +) + +// GlobalIdentities loads age identities from the [SopsAgeKeyEnv] environment variable. +func GlobalIdentities() ([]age.Identity, error) { + if globalKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok { + parsed, err := age.ParseIdentities(strings.NewReader(globalKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse age identities from env var: %w", err) + } + return parsed, nil + } + + return nil, nil +} + // MasterKey is an age key used to Encrypt and Decrypt SOPS' data key. // // Adapted from https://github.com/mozilla/sops/blob/v3.7.2/age/keysource.go