From 163fc1c73401f59b2dd3cee9f58de872c72302b6 Mon Sep 17 00:00:00 2001 From: Tim Heurich Date: Wed, 13 Sep 2023 09:27:00 +0200 Subject: [PATCH] feat: multidoc support for yaml and json (#1304) * feat: YAML & JSON MultiDoc Support for Sealing and Validation Fixes #114 Signed-off-by: Tim Heurich --- README.md | 4 + pkg/kubeseal/kubeseal.go | 243 +++++++++++++++++++++------------- pkg/kubeseal/kubeseal_test.go | 110 ++++++++++++--- 3 files changed, 247 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 87d18f9786..51b3e770cb 100644 --- a/README.md +++ b/README.md @@ -716,6 +716,10 @@ Developing guidelines can be found [in the Developer Guide](docs/developer/READM ## FAQ +### Can I encrypt multiple secrets at once, in one YAML / JSON file? + +Yes, you can! Drop as many secrets as you like in one file. Make sure to separate them via `---` for YAML and as extra, single objects in JSON. + ### Will you still be able to decrypt if you no longer have access to your cluster? No, the private keys are only stored in the Secret managed by the controller (unless you have some other backup of your k8s objects). There are no backdoors - without that private key used to encrypt a given SealedSecrets, you can't decrypt it. If you can't get to the Secrets with the encryption keys, and you also can't get to the decrypted versions of your Secrets live in the cluster, then you will need to regenerate new passwords for everything, seal them again with a new sealing key, etc. diff --git a/pkg/kubeseal/kubeseal.go b/pkg/kubeseal/kubeseal.go index fdce3876a8..2de6ca9451 100644 --- a/pkg/kubeseal/kubeseal.go +++ b/pkg/kubeseal/kubeseal.go @@ -13,12 +13,14 @@ import ( "net/http" "net/url" "os" + "reflect" "strings" "time" + "k8s.io/apimachinery/pkg/util/yaml" + ssv1alpha1 "github.com/bitnami-labs/sealed-secrets/pkg/apis/sealedsecrets/v1alpha1" "github.com/bitnami-labs/sealed-secrets/pkg/crypto" - "github.com/bitnami-labs/sealed-secrets/pkg/multidocyaml" v1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -66,24 +68,6 @@ func ParseKey(r io.Reader) (*rsa.PublicKey, error) { return cert, nil } -func readSecret(codec runtime.Decoder, r io.Reader) (*v1.Secret, error) { - data, err := io.ReadAll(r) - if err != nil { - return nil, err - } - - if err := multidocyaml.EnsureNotMultiDoc(data); err != nil { - return nil, err - } - - var ret v1.Secret - if err = runtime.DecodeInto(codec, data, &ret); err != nil { - return nil, err - } - - return &ret, nil -} - func prettyEncoder(codecs runtimeserializer.CodecFactory, mediaType string, gv runtime.GroupVersioner) (runtime.Encoder, error) { info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType) if !ok { @@ -184,60 +168,112 @@ func OpenCert(ctx context.Context, clientConfig ClientConfig, controllerNs, cont return openCertCluster(ctx, restClient, controllerNs, controllerName) } +func readSecrets(r io.Reader) ([]*v1.Secret, error) { + decoder := yaml.NewYAMLOrJSONDecoder(r, 4096) + + var secrets []*v1.Secret + empty := v1.Secret{} + + for { + sec := v1.Secret{} + err := decoder.Decode(&sec) + if reflect.DeepEqual(sec, empty) { + if errors.Is(err, io.EOF) { + break + } else { + continue + } + } + secrets = append(secrets, &sec) + if err != nil && err != io.EOF { + return nil, err + } + } + return secrets, nil +} + +func readSealedSecrets(r io.Reader) ([]*ssv1alpha1.SealedSecret, error) { + decoder := yaml.NewYAMLOrJSONDecoder(r, 4096) + + var secrets []*ssv1alpha1.SealedSecret + empty := ssv1alpha1.SealedSecret{} + + for { + sec := ssv1alpha1.SealedSecret{} + err := decoder.Decode(&sec) + if reflect.DeepEqual(sec, empty) { + if errors.Is(err, io.EOF) { + break + } else { + continue + } + } + secrets = append(secrets, &sec) + if err != nil && err != io.EOF { + return nil, err + } + } + + return secrets, nil +} + // Seal reads a k8s Secret resource parsed from an input reader by a given codec, encrypts all its secrets // with a given public key, using the name and namespace found in the input secret, unless explicitly overridden // by the overrideName and overrideNamespace arguments. func Seal(clientConfig ClientConfig, outputFormat string, in io.Reader, out io.Writer, codecs runtimeserializer.CodecFactory, pubKey *rsa.PublicKey, scope ssv1alpha1.SealingScope, allowEmptyData bool, overrideName, overrideNamespace string) error { - secret, err := readSecret(codecs.UniversalDecoder(), in) + secrets, err := readSecrets(in) if err != nil { return err } - if len(secret.Data) == 0 && len(secret.StringData) == 0 && !allowEmptyData { - return fmt.Errorf("secret.data is empty in input Secret, assuming this is an error and aborting. To work with empty data, --allow-empty-data can be used") - } + for _, secret := range secrets { + if len(secret.Data) == 0 && len(secret.StringData) == 0 && !allowEmptyData { + return fmt.Errorf("secret.data is empty in input Secret, assuming this is an error and aborting. To work with empty data, --allow-empty-data can be used") + } - if overrideName != "" { - secret.Name = overrideName - } + if overrideName != "" { + secret.Name = overrideName + } - if secret.GetName() == "" { - return fmt.Errorf("missing metadata.name in input Secret") - } + if secret.GetName() == "" { + return fmt.Errorf("missing metadata.name in input Secret") + } - if overrideNamespace != "" { - secret.Namespace = overrideNamespace - } + if overrideNamespace != "" { + secret.Namespace = overrideNamespace + } - if scope != ssv1alpha1.DefaultScope { - secret.Annotations = ssv1alpha1.UpdateScopeAnnotations(secret.Annotations, scope) - } + if scope != ssv1alpha1.DefaultScope { + secret.Annotations = ssv1alpha1.UpdateScopeAnnotations(secret.Annotations, scope) + } - if ssv1alpha1.SecretScope(secret) != ssv1alpha1.ClusterWideScope && secret.GetNamespace() == "" { - ns, _, err := clientConfig.Namespace() - if clientcmd.IsEmptyConfig(err) { - return fmt.Errorf("input secret has no namespace and cannot infer the namespace automatically when no kube config is available") - } else if err != nil { - return err + if ssv1alpha1.SecretScope(secret) != ssv1alpha1.ClusterWideScope && secret.GetNamespace() == "" { + ns, _, err := clientConfig.Namespace() + if clientcmd.IsEmptyConfig(err) { + return fmt.Errorf("input secret has no namespace and cannot infer the namespace automatically when no kube config is available") + } else if err != nil { + return err + } + secret.SetNamespace(ns) } - secret.SetNamespace(ns) - } - // Strip read-only server-side ObjectMeta (if present) - secret.SetSelfLink("") - secret.SetUID("") - secret.SetResourceVersion("") - secret.Generation = 0 - secret.SetCreationTimestamp(metav1.Time{}) - secret.SetDeletionTimestamp(nil) - secret.DeletionGracePeriodSeconds = nil + // Strip read-only server-side ObjectMeta (if present) + secret.SetSelfLink("") + secret.SetUID("") + secret.SetResourceVersion("") + secret.Generation = 0 + secret.SetCreationTimestamp(metav1.Time{}) + secret.SetDeletionTimestamp(nil) + secret.DeletionGracePeriodSeconds = nil - ssecret, err := ssv1alpha1.NewSealedSecret(codecs, pubKey, secret) - if err != nil { - return err - } - if err = sealedSecretOutput(out, outputFormat, codecs, ssecret); err != nil { - return err + ssecret, err := ssv1alpha1.NewSealedSecret(codecs, pubKey, secret) + if err != nil { + return err + } + if err = sealedSecretOutput(out, outputFormat, codecs, ssecret); err != nil { + return err + } + //return nil } return nil } @@ -256,11 +292,6 @@ func ValidateSealedSecret(ctx context.Context, clientConfig ClientConfig, contro return err } - content, err := io.ReadAll(in) - if err != nil { - return err - } - req := restClient.RESTClient().Post(). Namespace(controllerNs). Resource("services"). @@ -268,15 +299,25 @@ func ValidateSealedSecret(ctx context.Context, clientConfig ClientConfig, contro Name(net.JoinSchemeNamePort("http", controllerName, portName)). Suffix("/v1/verify") - req.Body(content) - res := req.Do(ctx) - if err := res.Error(); err != nil { - if status, ok := err.(*k8serrors.StatusError); ok && status.Status().Code == http.StatusConflict { - return fmt.Errorf("unable to decrypt sealed secret") - } - return fmt.Errorf("cannot validate sealed secret: %v", err) + secrets, err := readSealedSecrets(in) + if err != nil { + return fmt.Errorf("unable to decrypt sealed secret") } + for _, secret := range secrets { + content, err := json.Marshal(secret) + if err != nil { + return fmt.Errorf("error while marshalling sealed secret: %w", err) + } + req.Body(content) + res := req.Do(ctx) + if err := res.Error(); err != nil { + if status, ok := err.(*k8serrors.StatusError); ok && status.Status().Code == http.StatusConflict { + return fmt.Errorf("unable to decrypt sealed secret: %v", secret.GetName()) + } + return fmt.Errorf("cannot validate sealed secret: %v", err) + } + } return nil } @@ -294,7 +335,6 @@ func ReEncryptSealedSecret(ctx context.Context, clientConfig ClientConfig, contr return err } - content, err := io.ReadAll(in) if err != nil { return err } @@ -306,27 +346,38 @@ func ReEncryptSealedSecret(ctx context.Context, clientConfig ClientConfig, contr Name(net.JoinSchemeNamePort("http", controllerName, portName)). Suffix("/v1/rotate") - req.Body(content) - res := req.Do(ctx) - if err := res.Error(); err != nil { - if status, ok := err.(*k8serrors.StatusError); ok && status.Status().Code == http.StatusConflict { - return fmt.Errorf("unable to rotate secret") - } - return fmt.Errorf("cannot re-encrypt secret: %v", err) - } - body, err := res.Raw() + secrets, err := readSealedSecrets(in) if err != nil { return err } - ssecret := &ssv1alpha1.SealedSecret{} - if err = json.Unmarshal(body, ssecret); err != nil { - return err - } - ssecret.SetCreationTimestamp(metav1.Time{}) - ssecret.SetDeletionTimestamp(nil) - ssecret.Generation = 0 - if err = sealedSecretOutput(out, outputFormat, codecs, ssecret); err != nil { - return err + + for _, secret := range secrets { + content, err := json.Marshal(secret) + if err != nil { + return err + } + req.Body(content) + res := req.Do(ctx) + if err := res.Error(); err != nil { + if status, ok := err.(*k8serrors.StatusError); ok && status.Status().Code == http.StatusConflict { + return fmt.Errorf("unable to rotate secret") + } + return fmt.Errorf("cannot re-encrypt secret: %v", err) + } + body, err := res.Raw() + if err != nil { + return err + } + ssecret := &ssv1alpha1.SealedSecret{} + if err = json.Unmarshal(body, ssecret); err != nil { + return err + } + ssecret.SetCreationTimestamp(metav1.Time{}) + ssecret.SetDeletionTimestamp(nil) + ssecret.Generation = 0 + if err = sealedSecretOutput(out, outputFormat, codecs, ssecret); err != nil { + return err + } } return nil } @@ -350,7 +401,13 @@ func resourceOutput(out io.Writer, outputFormat string, codecs runtimeserializer return err } _, _ = out.Write(buf) - fmt.Fprint(out, "\n") + + switch contentType { + case runtime.ContentTypeJSON: + fmt.Fprint(out, "\n") + case runtime.ContentTypeYAML: + fmt.Fprint(out, "---\n") + } return nil } @@ -471,19 +528,19 @@ func readPrivKeysFromFile(filename string) ([]*rsa.PrivateKey, error) { var lst v1.List if err = runtime.DecodeInto(scheme.Codecs.UniversalDecoder(), b, &lst); err == nil { for _, r := range lst.Items { - s, err := readSecret(scheme.Codecs.UniversalDecoder(), bytes.NewBuffer(r.Raw)) + s, err := readSecrets(bytes.NewBuffer(r.Raw)) if err != nil { return nil, err } - secrets = append(secrets, s) + secrets = append(secrets, s...) } } else { // try to parse it as json/yaml encoded secret - s, err := readSecret(scheme.Codecs.UniversalDecoder(), bytes.NewBuffer(b)) + s, err := readSecrets(bytes.NewBuffer(b)) if err != nil { return nil, err } - secrets = append(secrets, s) + secrets = append(secrets, s...) } var keys []*rsa.PrivateKey diff --git a/pkg/kubeseal/kubeseal_test.go b/pkg/kubeseal/kubeseal_test.go index 56b7ebd743..bfa79abe67 100644 --- a/pkg/kubeseal/kubeseal_test.go +++ b/pkg/kubeseal/kubeseal_test.go @@ -19,6 +19,9 @@ import ( "testing" "time" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/utils/strings/slices" + flag "github.com/spf13/pflag" "github.com/google/go-cmp/cmp" @@ -177,6 +180,86 @@ func TestOpenCertFile(t *testing.T) { } } +func TestSealWithMultiDocSecrets(t *testing.T) { + key, err := ParseKey(strings.NewReader(testCert)) + if err != nil { + t.Fatalf("Failed to parse gotSecrets key: %v", err) + } + + testCases := []struct { + name string + asYaml bool + inputSeparator string + outputFormat string + }{ + { + name: "multi-doc json", + asYaml: false, + inputSeparator: "\n", + outputFormat: "json", + }, + { + name: "multi-doc yaml", + asYaml: true, + inputSeparator: "---\n", + outputFormat: "yaml", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s1 := mkTestSecret(t, "foo", "1", withSecretName("s1"), asYAML(tc.asYaml)) + s2 := mkTestSecret(t, "bar", "2", withSecretName("s2"), asYAML(tc.asYaml)) + multiDocYaml := fmt.Sprintf("%s%s%s", s1, tc.inputSeparator, s2) + + clientConfig := testClientConfig() + outputFormat := tc.outputFormat + inbuf := bytes.Buffer{} + _, err = bytes.NewBuffer([]byte(multiDocYaml)).WriteTo(&inbuf) + if err != nil { + t.Fatalf("Error writing to buffer: %v", err) + } + + t.Logf("input is: %s", inbuf.String()) + + outbuf := bytes.Buffer{} + if err := Seal(clientConfig, outputFormat, &inbuf, &outbuf, scheme.Codecs, key, ssv1alpha1.NamespaceWideScope, false, "", ""); err != nil { + t.Fatalf("seal() returned error: %v", err) + } + + outBytes := outbuf.Bytes() + t.Logf("output is %s", outBytes) + + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(outBytes), 4096) + var gotSecrets []*ssv1alpha1.SealedSecret + for { + s := ssv1alpha1.SealedSecret{} + err := decoder.Decode(&s) + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("Failed to parse result: %v", err) + } + gotSecrets = append(gotSecrets, &s) + } + + if got, want := len(gotSecrets), 2; got != want { + t.Errorf("Wrong element output length: got: %d, want: %d", got, want) + } + + for _, gotSecret := range gotSecrets { + if got, want := gotSecret.GetNamespace(), "testns"; got != want { + t.Errorf("got: %q, want: %q", got, want) + } + if got, want := gotSecret.GetName(), []string{"s1", "s2"}; !slices.Contains(want, got) { + t.Errorf("got: %q, want: %q", got, want) + } + } + }) + } +} + func TestSeal(t *testing.T) { key, err := ParseKey(strings.NewReader(testCert)) if err != nil { @@ -520,13 +603,15 @@ func TestUnseal(t *testing.T) { t.Fatal(err) } - secret, err := readSecret(scheme.Codecs.UniversalDecoder(), &buf) + secret, err := readSecrets(&buf) if err != nil { t.Fatal(err) } - if got, want := string(secret.Data[secretItemKey]), secretItemValue; got != want { - t.Fatalf("got: %q, want: %q", got, want) + for _, secret := range secret { + if got, want := string(secret.Data[secretItemKey]), secretItemValue; got != want { + t.Fatalf("got: %q, want: %q", got, want) + } } } @@ -579,13 +664,15 @@ func TestUnsealList(t *testing.T) { t.Fatal(err) } - secret, err := readSecret(scheme.Codecs.UniversalDecoder(), &buf) + secret, err := readSecrets(&buf) if err != nil { t.Fatal(err) } - if got, want := string(secret.Data[secretItemKey]), secretItemValue; got != want { - t.Fatalf("got: %q, want: %q", got, want) + for _, secret := range secret { + if got, want := string(secret.Data[secretItemKey]), secretItemValue; got != want { + t.Fatalf("got: %q, want: %q", got, want) + } } } @@ -887,17 +974,6 @@ func TestReadPrivKeySecret(t *testing.T) { } } -func TestYAMLStream(t *testing.T) { - s1 := mkTestSecret(t, "foo", "1", withSecretName("s1"), asYAML(true)) - s2 := mkTestSecret(t, "var", "2", withSecretName("s2"), asYAML(true)) - bad := fmt.Sprintf("%s\n---\n%s\n", s1, s2) - - _, err := readSecret(scheme.Codecs.UniversalDecoder(), strings.NewReader(bad)) - if err == nil { - t.Fatalf("error expected") - } -} - func TestReadPrivKeyPEM(t *testing.T) { _, pkw := newTestKeyPairSingle(t)