diff --git a/README.md b/README.md index 372f21f7..daebd69d 100644 --- a/README.md +++ b/README.md @@ -53,31 +53,90 @@ $ make uninstall ## Usage -Add the annotation `secret-generator.v1.mittwald.de/autogenerate` to any Kubernetes -secret object. The value of the annotation can be a field name -(or comma separated list of field names) within the secret; the -SecretGeneratorController will pick up this annotation and add a field [or fields] +This operator is capable of generating secure random strings and ssh keypair secrets. + +The type of secret to be generated can be specified by the `secret-generator.v1.mittwald.de/type` annotation. +This annotation can be added to any Kubernetes secret object in the operators `watchNamespace`. + +### Secure Random Strings + +By default, the operator will generate secure random strings. If the type annotation is not present, it will be added after the first +reconciliation loop and its value will be set to `string`. + +To actually generate random string secrets, the `secret-generator.v1.mittwald.de/autogenerate` annotation is required as well. +The value of the annotation can be a field name (or comma separated list of field names) within the secret; +the SecretGeneratorController will pick up this annotation and add a field [or fields] (`password` in the example below) to the secret with a randomly generated string value. ```yaml apiVersion: v1 kind: Secret metadata: + name: string-secret annotations: secret-generator.v1.mittwald.de/autogenerate: password data: username: c29tZXVzZXI= ``` -## Operational tasks +after reconciliation: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: string-secret + annotations: + secret-generator.v1.mittwald.de/type: string + secret-generator.v1.mittwald.de/secure: "yes" + secret-generator.v1.mittwald.de/autogenerate: password + secret-generator.v1.mittwald.de/autogenerate-generated-at: "2020-04-03T14:07:47+02:00" +type: Opaque +data: + username: c29tZXVzZXI= + password: TWVwSU83L2huNXBralNTMHFwU3VKSkkwNmN4NmRpNTBBcVpuVDlLOQ== +``` + +### SSH Key Pairs + +To generate SSH Key Pairs, the `secret-generator.v1.mittwald.de/type` annotation **has** to be present on the kubernetes secret object. + +The operator will then add two keys to the secret object, `ssh-publickey` and `ssh-privatekey`, each containing the respective key. -- Regenerate all automatically generated passwords: +The Private Key will be PEM encoded, the Public Key will have the authorized-keys format. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + annotations: + secret-generator.v1.mittwald.de/type: ssh-keypair +data: {} +``` + +after reconciliation: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + annotations: + secret-generator.v1.mittwald.de/type: ssh-keypair + secret-generator.v1.mittwald.de/autogenerate-generated-at: "2020-04-03T14:07:47+02:00" +type: Opaque +data: + ssh-publickey: c3NoLXJzYSBBQUFBQ... + ssh-privatekey: LS0tLS1CRUdJTi... +``` + +## Operational tasks +- Regenerate all automatically generated secrets: ``` $ kubectl annotate secrets --all secret-generator.v1.mittwald.de/regenerate=true ``` -- Regenerate only certain fields +- Regenerate only certain fields, in case the secret is of the `password` type: ``` $ kubectl annotate secrets --all secret-generator.v1.mittwald.de/regenerate=password1,password2 ``` diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 81882079..afe235d4 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -62,6 +62,7 @@ func main() { pflag.Bool("regenerate-insecure", false, "Set this to automatically regenerate secrets that were generated with an non-cryptographically secure PRNG.") pflag.Int("secret-length", 40, "Secret length") + pflag.Int("ssh-key-length", 2048, "Default length of SSH Keys") pflag.Parse() @@ -83,6 +84,10 @@ func main() { panic(fmt.Errorf("parameter secret-length is set to 0")) } + if viper.GetInt("ssh-key-length") == 0 { + panic(fmt.Errorf("parameter ssh-key-length is set to 0")) + } + // Use a zap logr.Logger implementation. If none of the zap // flags are configured (or if the zap flag set is not being // used), this defaults to a production zap logger. diff --git a/go.mod b/go.mod index fcdd5416..c6d2fb1b 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/mittwald/kubernetes-secret-generator go 1.13 require ( + github.com/go-logr/logr v0.1.0 github.com/google/uuid v1.1.1 github.com/imdario/mergo v0.3.8 github.com/operator-framework/operator-sdk v0.16.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.4.0 github.com/stretchr/testify v1.4.0 + golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152 k8s.io/api v0.0.0 k8s.io/apimachinery v0.0.0 k8s.io/client-go v12.0.0+incompatible diff --git a/pkg/controller/secret/secret_controller.go b/pkg/controller/secret/secret_controller.go index c25f3327..cb40609e 100644 --- a/pkg/controller/secret/secret_controller.go +++ b/pkg/controller/secret/secret_controller.go @@ -2,9 +2,6 @@ package secret import ( "context" - "crypto/rand" - "encoding/base64" - "fmt" "github.com/spf13/viper" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -17,19 +14,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/source" - "strings" + "strconv" "time" ) var log = logf.Log.WithName("controller_secret") -const ( - SecretGenerateAnnotation = "secret-generator.v1.mittwald.de/autogenerate" - SecretGeneratedAtAnnotation = "secret-generator.v1.mittwald.de/autogenerate-generated-at" - SecretRegenerateAnnotation = "secret-generator.v1.mittwald.de/regenerate" - SecretSecureAnnotation = "secret-generator.v1.mittwald.de/secure" -) - func regenerateInsecure() bool { return viper.GetBool("regenerate-insecure") } @@ -38,6 +28,10 @@ func secretLength() int { return viper.GetInt("secret-length") } +func sshKeyLength() int { + return viper.GetInt("ssh-key-length") +} + // Add creates a new Secret Controller and adds it to the Manager. The Manager will set fields on the Controller // and Start it when the Manager is Started. func Add(mgr manager.Manager) error { @@ -102,71 +96,47 @@ func (r *ReconcileSecret) Reconcile(request reconcile.Request) (reconcile.Result desired := instance.DeepCopy() - toGenerate, ok := desired.Annotations[SecretGenerateAnnotation] - if !ok { - return reconcile.Result{}, nil - } - - reqLogger.Info("instance is autogenerated") - - genKeys := strings.Split(toGenerate, ",") + sType := SecretType(desired.Annotations[AnnotationSecretType]) + if err := sType.Validate(); err != nil { + if _, ok := desired.Annotations[AnnotationSecretAutoGenerate]; !ok && sType == "" { + // return if secret has no type and no autogenerate annotation + return reconcile.Result{}, nil + } - if err := ensureUniqueness(genKeys); err != nil { - return reconcile.Result{}, err + // keep backwards compatibility by defaulting to string type + desired.Annotations[AnnotationSecretType] = string(SecretTypeString) + sType = SecretTypeString } - var regenKeys []string - if _, ok := desired.Annotations[SecretSecureAnnotation]; !ok && regenerateInsecure() { - reqLogger.Info("instance was generated by cryptographically insecure PNRG") - regenKeys = genKeys // regenerate all keys - } else { - if regenerate, ok := desired.Annotations[SecretRegenerateAnnotation]; ok { - reqLogger.Info("removing regenerate annotation from instance") - delete(desired.Annotations, SecretRegenerateAnnotation) - - if regenerate == "yes" { - regenKeys = genKeys - } else { - regenKeys = strings.Split(regenerate, ",") // regenerate requested keys - } - } - } + reqLogger = reqLogger.WithValues("type", sType) + reqLogger.Info("instance is autogenerated") if desired.Data == nil { desired.Data = make(map[string][]byte) } - generatedCount := 0 - for _, key := range genKeys { - if len(desired.Data[key]) != 0 && !contains(regenKeys, key) { - // dont generate key if it already has a value - // and is not queued for regeneration - continue + var generator SecretGenerator + switch sType { + case SecretTypeSSHKeypair: + generator = SSHKeypairGenerator{ + log: reqLogger.WithValues("type", SecretTypeSSHKeypair), } - generatedCount++ - - value, err := generateSecret(secretLength()) - if err != nil { - reqLogger.Error(err, "could not generate new instance") - return reconcile.Result{RequeueAfter: time.Second * 30}, err + case SecretTypeString: + generator = StringGenerator{ + log: reqLogger.WithValues("type", SecretTypeString), } - - desired.Data[key] = []byte(value) - - reqLogger.Info("set field of instance to new randomly generated instance", "bytes", len(value), "field", key) } - reqLogger.Info("generated secrets", "count", generatedCount) - if generatedCount == len(genKeys) { - // all keys have been generated by this instance - desired.Annotations[SecretSecureAnnotation] = "yes" + res, err := generator.generateData(desired) + if err != nil { + return res, err } if !reflect.DeepEqual(instance.Annotations, desired.Annotations) || !reflect.DeepEqual(instance.Data, desired.Data) { reqLogger.Info("updating secret") - desired.Annotations[SecretGeneratedAtAnnotation] = time.Now().String() + desired.Annotations[AnnotationSecretAutoGeneratedAt] = time.Now().Format(time.RFC3339) err := r.client.Update(context.Background(), desired) if err != nil { reqLogger.Error(err, "could not update secret") @@ -177,33 +147,14 @@ func (r *ReconcileSecret) Reconcile(request reconcile.Request) (reconcile.Result return reconcile.Result{}, nil } -func generateSecret(length int) (string, error) { - b := make([]byte, length) - _, err := rand.Read(b) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(b)[0:length], nil -} - -func contains(s []string, e string) bool { - for _, a := range s { - if a == e { - return true - } - } - return false -} - -// ensure elements in input array are unique -func ensureUniqueness(a []string) error { - set := map[string]bool{} - for _, e := range a { - if set[e] { - return fmt.Errorf("duplicate element %s found", e) +func secretLengthFromAnnotation(fallback int, annotations map[string]string) (int, error) { + l := fallback + if val, ok := annotations[AnnotationSecretLength]; ok { + intVal, err := strconv.Atoi(val) + if err != nil { + return 0, err } - set[e] = true + l = intVal } - return nil + return l, nil } diff --git a/pkg/controller/secret/secret_controller_test.go b/pkg/controller/secret/secret_controller_test.go index c96a133e..f260cc16 100644 --- a/pkg/controller/secret/secret_controller_test.go +++ b/pkg/controller/secret/secret_controller_test.go @@ -1,10 +1,8 @@ package secret import ( - "bytes" "context" "github.com/google/uuid" - "github.com/imdario/mergo" "github.com/mittwald/kubernetes-secret-generator/pkg/apis" "github.com/spf13/viper" "github.com/stretchr/testify/require" @@ -16,14 +14,13 @@ import ( "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/flowcontrol" "os" + "reflect" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "strings" "testing" - "time" ) var mgr manager.Manager @@ -74,6 +71,7 @@ func TestMain(m *testing.M) { func setupViper() { viper.Set("secret-length", 40) viper.Set("regenerate-insecure", false) + viper.Set("ssh-key-length", 2048) } func reset() { @@ -110,117 +108,33 @@ func doReconcile(t *testing.T, secret *corev1.Secret, isErr bool) { require.False(t, res.Requeue) } -func newTestSecret(fields string, extraAnnotations map[string]string, initValues string) *corev1.Secret { - annotations := map[string]string{ - SecretGenerateAnnotation: fields, - } - if extraAnnotations != nil { - if err := mergo.Merge(&annotations, extraAnnotations, mergo.WithOverride); err != nil { - panic(err) - } - } - - s := &corev1.Secret{ +func TestDoesNotTouchOtherSecrets(t *testing.T) { + secret := &corev1.Secret{ + Type: corev1.SecretTypeOpaque, ObjectMeta: metav1.ObjectMeta{ Name: getSecretName(), Namespace: "default", Labels: map[string]string{ labelSecretGeneratorTest: "yes", }, - Annotations: annotations, }, - Type: corev1.SecretTypeOpaque, - Data: map[string][]byte{}, - } - - keys := strings.Split(fields, ",") - for i, init := range strings.Split(initValues, ",") { - s.Data[keys[i]] = []byte(init) - } - - return s -} - -// verify basic fields of the secret are present -func verifySecret(t *testing.T, in, out *corev1.Secret, secure bool) { - if out.Annotations[SecretSecureAnnotation] != "yes" && secure { - t.Errorf("generated secret has no %s annotation", SecretSecureAnnotation) - } else if out.Annotations[SecretSecureAnnotation] == "yes" && !secure { - t.Errorf("generated secret has %s annotation", SecretSecureAnnotation) - } - - _, wasGenerated := in.Annotations[SecretGeneratedAtAnnotation] - - for _, key := range strings.Split(in.Annotations[SecretGenerateAnnotation], ",") { - val, ok := out.Data[key] - if !ok { - t.Error("secret value has not been generated") - } - - // check if secret has correct length (if the secret has actually been generated) - if !wasGenerated && (len(val) == 0 || len(val) != secretLength()) { - t.Errorf("generated field has wrong length of %d", len(val)) - } - - t.Logf("generated secret value: %s", val) - } - - if _, ok := out.Annotations[SecretGeneratedAtAnnotation]; !ok { - t.Errorf("secret has no %s annotation", SecretGeneratedAtAnnotation) - } -} - -// verify requested keys have been regenerated -func verifyRegen(t *testing.T, in, out *corev1.Secret) { - if _, ok := out.Annotations[SecretRegenerateAnnotation]; ok { - t.Errorf("%s annotation is still present", SecretRegenerateAnnotation) - } - - if _, ok := in.Annotations[SecretRegenerateAnnotation]; !ok && !regenerateInsecure() { // test the tester - t.Errorf("%s annotation is not present on input", SecretRegenerateAnnotation) - } - - if _, ok := in.Annotations[SecretGeneratedAtAnnotation]; !ok { // test the tester - t.Errorf("%s annotation is not present on input", SecretGeneratedAtAnnotation) + Data: map[string][]byte{ + "testkey": []byte("test"), + "testkey2": []byte("test2"), + }, } - var regenKeys []string - if in.Annotations[SecretRegenerateAnnotation] == "yes" || - regenerateInsecure() && in.Annotations[SecretSecureAnnotation] == "" { - regenKeys = strings.Split(in.Annotations[SecretGenerateAnnotation], ",") - } else if in.Annotations[SecretRegenerateAnnotation] != "" { - regenKeys = strings.Split(in.Annotations[SecretRegenerateAnnotation], ",") - } + require.NoError(t, mgr.GetClient().Create(context.TODO(), secret)) - t.Logf("checking regenerated keys are regenerated and have correct length") - t.Logf("keys expected to be regenerated: %d", len(regenKeys)) - if len(regenKeys) != 0 { - for _, key := range regenKeys { - val := out.Data[key] - if len(val) == 0 || len(val) != secretLength() { - // check length here again, verifySecret skips this for secrets that already had the generatedAt Annotation - t.Errorf("regenerated field has wrong length of %d", len(val)) - } + doReconcile(t, secret, false) - if bytes.Equal(in.Data[key], val) { - t.Errorf("key %s is equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) - continue - } - t.Logf("key %s is NOT equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) - } - } + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: secret.Name, + Namespace: secret.Namespace}, out)) - t.Logf("checking generated keys are not regenerated") - genKeys := strings.Split(in.Annotations[SecretGenerateAnnotation], ",") - for _, key := range genKeys { - if stringInSlice(key, regenKeys) { - continue - } - if bytes.Equal(in.Data[key], out.Data[key]) { - t.Logf("key %s is equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) - continue - } - t.Errorf("key %s is NOT equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) + if !reflect.DeepEqual(secret, out) { + t.Errorf("secret without operator annotations has been reconciled") } } @@ -232,267 +146,3 @@ func stringInSlice(a string, list []string) bool { } return false } - -func TestGenerateSecretSingleField(t *testing.T) { - in := newTestSecret("testfield", nil, "") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) -} - -func TestGenerateSecretMultipleFields(t *testing.T) { - in := newTestSecret("testfield,test1,test2,test3,abc,12345,6789", nil, "") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) -} - -func TestRegenerateSingleField(t *testing.T) { - in := newTestSecret("testfield", map[string]string{ - SecretRegenerateAnnotation: "testfield", - SecretGeneratedAtAnnotation: time.Now().String(), - }, "test") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) -} - -func TestRegenerateAllSingleField(t *testing.T) { - in := newTestSecret("testfield", map[string]string{ - SecretRegenerateAnnotation: "yes", - SecretGeneratedAtAnnotation: time.Now().String(), - }, "test") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) -} - -func TestRegenerateMultipleFieldsSecure(t *testing.T) { - in := newTestSecret("testfield,test1,test2", map[string]string{ - SecretRegenerateAnnotation: "testfield", - SecretGeneratedAtAnnotation: time.Now().String(), - SecretSecureAnnotation: "yes", - }, "test,abc,def") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) -} - -func TestRegenerateMultipleFieldsNotSecure(t *testing.T) { - in := newTestSecret("testfield,test1,test2", map[string]string{ - SecretRegenerateAnnotation: "testfield", - SecretGeneratedAtAnnotation: time.Now().String(), - }, "test,abc,def") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, false) - verifyRegen(t, in, out) -} - -func TestRegenerateAllMultipleFields(t *testing.T) { - in := newTestSecret("testfield,test1,test2", map[string]string{ - SecretRegenerateAnnotation: "yes", - SecretGeneratedAtAnnotation: time.Now().String(), - }, "test,abc,def") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) -} - -func TestRegenerateInsecureSingleField(t *testing.T) { - viper.Set("regenerate-insecure", true) - in := newTestSecret("testfield", map[string]string{ - SecretGeneratedAtAnnotation: time.Now().String(), - }, "test") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) - viper.Set("regenerate-insecure", false) -} - -func TestRegenerateInsecureEmpty(t *testing.T) { - viper.Set("regenerate-insecure", true) - in := newTestSecret("testfield", nil, "") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - viper.Set("regenerate-insecure", false) -} - -func TestRegenerateInsecureSingleFieldSecureBefore(t *testing.T) { - viper.Set("regenerate-insecure", true) - in := newTestSecret("testfield", map[string]string{ - SecretGeneratedAtAnnotation: time.Now().String(), - SecretSecureAnnotation: "yes", - }, "test") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) - viper.Set("regenerate-insecure", false) -} - -func TestRegenerateInsecureMultipleField(t *testing.T) { - viper.Set("regenerate-insecure", true) - in := newTestSecret("testfield,test1,test2,test3", map[string]string{ - SecretGeneratedAtAnnotation: time.Now().String(), - }, "abc,def,ghi,jkl") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) - viper.Set("regenerate-insecure", false) -} - -func TestRegenerateInsecureMultipleFieldSecureBefore(t *testing.T) { - viper.Set("regenerate-insecure", true) - in := newTestSecret("testfield,test1,test2,test3", map[string]string{ - SecretGeneratedAtAnnotation: time.Now().String(), - SecretSecureAnnotation: "yes", - }, "abc,def,ghi,jkl") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, false) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) - - verifySecret(t, in, out, true) - verifyRegen(t, in, out) - viper.Set("regenerate-insecure", false) -} - -func TestUniqueness(t *testing.T) { - in := newTestSecret("testfield,abc,test,abc,oops,oops", nil, "") - require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) - - doReconcile(t, in, true) - - out := &corev1.Secret{} - require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace}, out)) -} - -func TestGeneratedSecretsHaveCorrectLength(t *testing.T) { - pwd, err := generateSecret(20) - - t.Log("generated", pwd) - - if err != nil { - t.Error(err) - } - - if len(pwd) != 20 { - t.Error("password length", "expected", 20, "got", len(pwd)) - } -} - -func TestGeneratedSecretsAreRandom(t *testing.T) { - one, errOne := generateSecret(32) - two, errTwo := generateSecret(32) - - if errOne != nil { - t.Error(errOne) - } - if errTwo != nil { - t.Error(errTwo) - } - - if one == two { - t.Error("password equality", "got", one) - } -} - -func BenchmarkGenerateSecret(b *testing.B) { - for i := 0; i < b.N; i++ { - _, err := generateSecret(32) - if err != nil { - b.Error(err) - } - } -} diff --git a/pkg/controller/secret/secret_ssh.go b/pkg/controller/secret/secret_ssh.go new file mode 100644 index 00000000..37492692 --- /dev/null +++ b/pkg/controller/secret/secret_ssh.go @@ -0,0 +1,126 @@ +package secret + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "github.com/go-logr/logr" + "golang.org/x/crypto/ssh" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "time" +) + +const ( + SecretFieldPublicKey = "ssh-publickey" + SecretFieldPrivateKey = "ssh-privatekey" +) + +type SSHKeypairGenerator struct { + log logr.Logger +} + +type SSHKeypair struct { + PrivateKey []byte + PublicKey []byte +} + +func (sg SSHKeypairGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) { + privateKey := instance.Data[SecretFieldPrivateKey] + publicKey := instance.Data[SecretFieldPublicKey] + + regenerate := instance.Annotations[AnnotationSecretRegenerate] != "" + + // check for existing values, if regeneration isn't forced + if len(privateKey) > 0 && !regenerate { + if len(publicKey) == 0 { + // restore public key if private key exists + rsaKey, err := privateKeyFromPEM(privateKey) + if err != nil { + return reconcile.Result{}, err + } + + publicKey, err = sshPublicKeyForPrivateKey(rsaKey) + if err != nil { + return reconcile.Result{}, err + } + + instance.Data[SecretFieldPublicKey] = publicKey + } + + // do nothing, both keys are present + return reconcile.Result{}, nil + } + + if regenerate { + delete(instance.Annotations, AnnotationSecretRegenerate) + } + + length, err := secretLengthFromAnnotation(sshKeyLength(), instance.Annotations) + if err != nil { + return reconcile.Result{}, err + } + + keyPair, err := generateSSHKeypair(length) + if err != nil { + return reconcile.Result{RequeueAfter: time.Second * 30}, err + } + + instance.Data[SecretFieldPublicKey] = keyPair.PublicKey + instance.Data[SecretFieldPrivateKey] = keyPair.PrivateKey + + return reconcile.Result{}, nil +} + +// generates ssh private and public key of given length +// the returned public key is in authorized-keys format +// the private key is PEM encoded +func generateSSHKeypair(length int) (SSHKeypair, error) { + key, err := rsa.GenerateKey(rand.Reader, length) + if err != nil { + return SSHKeypair{}, err + } + + privateKeyBytes := &bytes.Buffer{} + err = pem.Encode( + privateKeyBytes, + &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + if err != nil { + return SSHKeypair{}, err + } + + publicKey, err := sshPublicKeyForPrivateKey(key) + if err != nil { + return SSHKeypair{}, err + } + + return SSHKeypair{ + PublicKey: publicKey, + PrivateKey: privateKeyBytes.Bytes(), + }, nil +} + +func privateKeyFromPEM(pemKey []byte) (*rsa.PrivateKey, error) { + b, _ := pem.Decode(pemKey) + if b == nil { + return nil, errors.New("failed to parse private Key PEM block") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(b.Bytes) + if err != nil { + return nil, err + } + return privateKey, nil +} + +func sshPublicKeyForPrivateKey(privateKey *rsa.PrivateKey) ([]byte, error) { + publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, err + } + + return ssh.MarshalAuthorizedKey(publicKey), nil +} diff --git a/pkg/controller/secret/secret_ssh_test.go b/pkg/controller/secret/secret_ssh_test.go new file mode 100644 index 00000000..58f3a2b2 --- /dev/null +++ b/pkg/controller/secret/secret_ssh_test.go @@ -0,0 +1,216 @@ +package secret + +import ( + "bytes" + "context" + "github.com/imdario/mergo" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "testing" + "time" +) + +func newSSHKeypairTestSecret(t *testing.T, extraAnnotations map[string]string, initialized bool) *corev1.Secret { + annotations := map[string]string{ + AnnotationSecretType: string(SecretTypeSSHKeypair), + } + + if extraAnnotations != nil { + if err := mergo.Merge(&annotations, extraAnnotations, mergo.WithOverride); err != nil { + panic(err) + } + } + + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: getSecretName(), + Namespace: "default", + Labels: map[string]string{ + labelSecretGeneratorTest: "yes", + }, + Annotations: annotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + + if initialized { + keypair, err := generateSSHKeypair(sshKeyLength()) + if err != nil { + t.Error(err, "could not generate new ssh keypair") + } + s.Data[SecretFieldPublicKey] = keypair.PublicKey + s.Data[SecretFieldPrivateKey] = keypair.PrivateKey + + s.Annotations[AnnotationSecretAutoGeneratedAt] = time.Now().Format(time.RFC3339) + } + + return s +} + +func verifySSHKeypairSecret(t *testing.T, in, out *corev1.Secret) { + if out.Annotations[AnnotationSecretType] != string(SecretTypeSSHKeypair) { + t.Errorf("generated secret has wrong type %s on %s annotation", out.Annotations[AnnotationSecretType], AnnotationSecretType) + } + + if _, ok := out.Annotations[AnnotationSecretAutoGeneratedAt]; !ok { + t.Errorf("secret has no %s annotation", AnnotationSecretAutoGeneratedAt) + } + + publicKey := out.Data[SecretFieldPublicKey] + privateKey := out.Data[SecretFieldPrivateKey] + + if len(privateKey) == 0 || len(publicKey) == 0 { + t.Errorf("publicKey(%d) or privateKey(%d) have invalid length", len(publicKey), len(privateKey)) + } + + key, err := privateKeyFromPEM(privateKey) + if err != nil { + t.Error(err, "generated private key could not be parsed") + } + + err = key.Validate() + if err != nil { + t.Error(err, "key validation failed") + } + + pub, err := sshPublicKeyForPrivateKey(key) + if err != nil { + t.Error(err, "generated public key could not be parsed") + } + + if !bytes.Equal(publicKey, pub) { + t.Error("publicKey doesn't match private key") + } +} + +func verifySSHKeypairRegen(t *testing.T, in, out *corev1.Secret, regenDesired bool) { + if _, ok := out.Annotations[AnnotationSecretRegenerate]; ok { + t.Errorf("%s annotation is still present", AnnotationSecretRegenerate) + } + + if _, ok := in.Annotations[AnnotationSecretRegenerate]; !ok && regenDesired { // test the tester + t.Errorf("%s annotation is not present on input", AnnotationSecretRegenerate) + } + + if _, ok := in.Annotations[AnnotationSecretAutoGeneratedAt]; !ok { // test the tester + t.Errorf("%s annotation is not present on input", AnnotationSecretAutoGeneratedAt) + } + + t.Logf("checking if keys have been regenerated") + oldPublicKey := in.Data[SecretFieldPublicKey] + oldPrivateKey := in.Data[SecretFieldPrivateKey] + + newPublicKey := out.Data[SecretFieldPublicKey] + newPrivateKey := out.Data[SecretFieldPrivateKey] + + equal := bytes.Equal(oldPublicKey, newPublicKey) + if equal && regenDesired { + t.Error("publicKey has not been regenerated") + } else if !equal && !regenDesired { + t.Error("publicKey has been regenerated") + } + + equal = bytes.Equal(oldPrivateKey, newPrivateKey) + if equal && regenDesired { + t.Error("privateKey has not been regenerated") + } else if !equal && !regenDesired { + t.Error("privateKey has been regenerated") + } +} + +func TestSSHKeypairIsGenerated(t *testing.T) { + in := newSSHKeypairTestSecret(t, nil, false) + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifySSHKeypairSecret(t, in, out) +} + +func TestSSHKeypairIsNotRegenerated(t *testing.T) { + in := newSSHKeypairTestSecret(t, nil, true) + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifySSHKeypairSecret(t, in, out) + verifySSHKeypairRegen(t, in, out, false) +} + +func TestSSHKeypairIsRegenerated(t *testing.T) { + in := newSSHKeypairTestSecret(t, map[string]string{ + AnnotationSecretRegenerate: "true", + }, true) + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifySSHKeypairSecret(t, in, out) + verifySSHKeypairRegen(t, in, out, true) +} + +func TestSSHKeypairLengthAnnotation(t *testing.T) { + in := newSSHKeypairTestSecret(t, map[string]string{ + AnnotationSecretRegenerate: "true", + AnnotationSecretLength: "4096", + }, true) + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifySSHKeypairSecret(t, in, out) + + key, err := privateKeyFromPEM(out.Data[SecretFieldPrivateKey]) + if err != nil { + t.Error(err, "generated private key could not be parsed") + } + + // Size() returns size in bytes + if key.Size()*8 != 4096 { + t.Error(err, "wrong generated secret length") + } +} + +func TestSSHKeypairLengthDefault(t *testing.T) { + in := newSSHKeypairTestSecret(t, map[string]string{ + AnnotationSecretRegenerate: "true", + }, true) + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifySSHKeypairSecret(t, in, out) + + key, err := privateKeyFromPEM(out.Data[SecretFieldPrivateKey]) + if err != nil { + t.Error(err, "generated private key could not be parsed") + } + + // Size() returns size in bytes + if key.Size()*8 != 2048 { + t.Error("wrong generated secret length") + } +} diff --git a/pkg/controller/secret/secret_string.go b/pkg/controller/secret/secret_string.go new file mode 100644 index 00000000..64004160 --- /dev/null +++ b/pkg/controller/secret/secret_string.go @@ -0,0 +1,105 @@ +package secret + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "strings" + "time" +) + +type StringGenerator struct { + log logr.Logger +} + +func (pg StringGenerator) generateData(instance *corev1.Secret) (reconcile.Result, error) { + toGenerate := instance.Annotations[AnnotationSecretAutoGenerate] // won't generate anything if annotation is not set + + genKeys := strings.Split(toGenerate, ",") + + if err := ensureUniqueness(genKeys); err != nil { + return reconcile.Result{}, err + } + + var regenKeys []string + if _, ok := instance.Annotations[AnnotationSecretSecure]; !ok && regenerateInsecure() { + pg.log.Info("instance was generated by a cryptographically insecure PRNG") + regenKeys = genKeys // regenerate all keys + } else if regenerate, ok := instance.Annotations[AnnotationSecretRegenerate]; ok { + pg.log.Info("removing regenerate annotation from instance") + delete(instance.Annotations, AnnotationSecretRegenerate) + + if regenerate == "yes" { + regenKeys = genKeys + } else { + regenKeys = strings.Split(regenerate, ",") // regenerate requested keys + } + } + + length, err := secretLengthFromAnnotation(secretLength(), instance.Annotations) + if err != nil { + return reconcile.Result{}, err + } + + generatedCount := 0 + for _, key := range genKeys { + if len(instance.Data[key]) != 0 && !contains(regenKeys, key) { + // dont generate key if it already has a value + // and is not queued for regeneration + continue + } + generatedCount++ + + value, err := generateRandomString(length) + if err != nil { + pg.log.Error(err, "could not generate new instance") + return reconcile.Result{RequeueAfter: time.Second * 30}, err + } + + instance.Data[key] = []byte(value) + + pg.log.Info("set field of instance to new randomly generated instance", "bytes", len(value), "field", key) + } + pg.log.Info("generated secrets", "count", generatedCount) + + if generatedCount == len(genKeys) { + // all keys have been generated by this instance + instance.Annotations[AnnotationSecretSecure] = "yes" + } + + return reconcile.Result{}, nil +} + +func generateRandomString(length int) (string, error) { + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(b)[0:length], nil +} + +// ensure elements in input array are unique +func ensureUniqueness(a []string) error { + set := map[string]bool{} + for _, e := range a { + if set[e] { + return fmt.Errorf("duplicate element %s found", e) + } + set[e] = true + } + return nil +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/pkg/controller/secret/secret_string_test.go b/pkg/controller/secret/secret_string_test.go new file mode 100644 index 00000000..a185e6d0 --- /dev/null +++ b/pkg/controller/secret/secret_string_test.go @@ -0,0 +1,452 @@ +package secret + +import ( + "bytes" + "context" + "github.com/imdario/mergo" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "strings" + "testing" + "time" +) + +func newStringTestSecret(fields string, extraAnnotations map[string]string, initValues string) *corev1.Secret { + annotations := map[string]string{ + AnnotationSecretAutoGenerate: fields, + } + if extraAnnotations != nil { + if err := mergo.Merge(&annotations, extraAnnotations, mergo.WithOverride); err != nil { + panic(err) + } + } + + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: getSecretName(), + Namespace: "default", + Labels: map[string]string{ + labelSecretGeneratorTest: "yes", + }, + Annotations: annotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + + keys := strings.Split(fields, ",") + for i, init := range strings.Split(initValues, ",") { + s.Data[keys[i]] = []byte(init) + } + + return s +} + +// verify basic fields of the secret are present +func verifyStringSecret(t *testing.T, in, out *corev1.Secret, secure bool) { + if out.Annotations[AnnotationSecretType] != string(SecretTypeString) { + t.Errorf("generated secret has wrong type %s on %s annotation", out.Annotations[AnnotationSecretType], AnnotationSecretType) + } + + if out.Annotations[AnnotationSecretSecure] != "yes" && secure { + t.Errorf("generated secret has no %s annotation", AnnotationSecretSecure) + } else if out.Annotations[AnnotationSecretSecure] == "yes" && !secure { + t.Errorf("generated secret has %s annotation", AnnotationSecretSecure) + } + + _, wasGenerated := in.Annotations[AnnotationSecretAutoGeneratedAt] + + for _, key := range strings.Split(in.Annotations[AnnotationSecretAutoGenerate], ",") { + val, ok := out.Data[key] + if !ok { + t.Error("secret value has not been generated") + } + + // check if secret has correct length (if the secret has actually been generated) + if !wasGenerated && (len(val) == 0 || len(val) != desiredLength(in)) { + t.Errorf("generated field has wrong length of %d", len(val)) + } + + t.Logf("generated secret value: %s", val) + } + + if _, ok := out.Annotations[AnnotationSecretAutoGeneratedAt]; !ok { + t.Errorf("secret has no %s annotation", AnnotationSecretAutoGeneratedAt) + } +} + +func desiredLength(s *corev1.Secret) int { + res, err := secretLengthFromAnnotation(secretLength(), s.Annotations) + if err != nil { + res = secretLength() + } + return res +} + +// verify requested keys have been regenerated +func verifyStringRegen(t *testing.T, in, out *corev1.Secret) { + if _, ok := out.Annotations[AnnotationSecretRegenerate]; ok { + t.Errorf("%s annotation is still present", AnnotationSecretRegenerate) + } + + if _, ok := in.Annotations[AnnotationSecretRegenerate]; !ok && !regenerateInsecure() { // test the tester + t.Errorf("%s annotation is not present on input", AnnotationSecretRegenerate) + } + + if _, ok := in.Annotations[AnnotationSecretAutoGeneratedAt]; !ok { // test the tester + t.Errorf("%s annotation is not present on input", AnnotationSecretAutoGeneratedAt) + } + + var regenKeys []string + if in.Annotations[AnnotationSecretRegenerate] == "yes" || + regenerateInsecure() && in.Annotations[AnnotationSecretSecure] == "" { + regenKeys = strings.Split(in.Annotations[AnnotationSecretAutoGenerate], ",") + } else if in.Annotations[AnnotationSecretRegenerate] != "" { + regenKeys = strings.Split(in.Annotations[AnnotationSecretRegenerate], ",") + } + + t.Logf("checking regenerated keys are regenerated and have correct length") + t.Logf("keys expected to be regenerated: %d", len(regenKeys)) + if len(regenKeys) != 0 { + for _, key := range regenKeys { + val := out.Data[key] + if len(val) == 0 || len(val) != secretLength() { + // check length here again, verifyStringSecret skips this for secrets that already had the generatedAt Annotation + t.Errorf("regenerated field has wrong length of %d", len(val)) + } + + if bytes.Equal(in.Data[key], val) { + t.Errorf("key %s is equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) + continue + } + t.Logf("key %s is NOT equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) + } + } + + t.Logf("checking generated keys are not regenerated") + genKeys := strings.Split(in.Annotations[AnnotationSecretAutoGenerate], ",") + for _, key := range genKeys { + if stringInSlice(key, regenKeys) { + continue + } + if bytes.Equal(in.Data[key], out.Data[key]) { + t.Logf("key %s is equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) + continue + } + t.Errorf("key %s is NOT equal for in(%s) and out (%s)", key, in.Data[key], out.Data[key]) + } +} + +func TestGenerateSecretSingleField(t *testing.T) { + in := newStringTestSecret("testfield", nil, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) +} + +func TestGenerateSecretMultipleFields(t *testing.T) { + in := newStringTestSecret("testfield,test1,test2,test3,abc,12345,6789", nil, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) +} + +func TestRegenerateSingleField(t *testing.T) { + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretRegenerate: "testfield", + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "test") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) +} + +func TestRegenerateAllSingleField(t *testing.T) { + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretRegenerate: "yes", + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "test") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) +} + +func TestRegenerateMultipleFieldsSecure(t *testing.T) { + in := newStringTestSecret("testfield,test1,test2", map[string]string{ + AnnotationSecretRegenerate: "testfield", + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + AnnotationSecretSecure: "yes", + }, "test,abc,def") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) +} + +func TestRegenerateMultipleFieldsNotSecure(t *testing.T) { + in := newStringTestSecret("testfield,test1,test2", map[string]string{ + AnnotationSecretRegenerate: "testfield", + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "test,abc,def") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, false) + verifyStringRegen(t, in, out) +} + +func TestRegenerateAllMultipleFields(t *testing.T) { + in := newStringTestSecret("testfield,test1,test2", map[string]string{ + AnnotationSecretRegenerate: "yes", + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "test,abc,def") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) +} + +func TestRegenerateInsecureSingleField(t *testing.T) { + viper.Set("regenerate-insecure", true) + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "test") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) + viper.Set("regenerate-insecure", false) +} + +func TestRegenerateInsecureEmpty(t *testing.T) { + viper.Set("regenerate-insecure", true) + in := newStringTestSecret("testfield", nil, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + viper.Set("regenerate-insecure", false) +} + +func TestRegenerateInsecureSingleFieldSecureBefore(t *testing.T) { + viper.Set("regenerate-insecure", true) + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + AnnotationSecretSecure: "yes", + }, "test") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) + viper.Set("regenerate-insecure", false) +} + +func TestRegenerateInsecureMultipleField(t *testing.T) { + viper.Set("regenerate-insecure", true) + in := newStringTestSecret("testfield,test1,test2,test3", map[string]string{ + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + }, "abc,def,ghi,jkl") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) + viper.Set("regenerate-insecure", false) +} + +func TestRegenerateInsecureMultipleFieldSecureBefore(t *testing.T) { + viper.Set("regenerate-insecure", true) + in := newStringTestSecret("testfield,test1,test2,test3", map[string]string{ + AnnotationSecretAutoGeneratedAt: time.Now().Format(time.RFC3339), + AnnotationSecretSecure: "yes", + }, "abc,def,ghi,jkl") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + + verifyStringSecret(t, in, out, true) + verifyStringRegen(t, in, out) + viper.Set("regenerate-insecure", false) +} + +func TestUniqueness(t *testing.T) { + in := newStringTestSecret("testfield,abc,test,abc,oops,oops", nil, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, true) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) +} + +func TestDefaultToStringGeneration(t *testing.T) { + in := newStringTestSecret("testfield", nil, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifyStringSecret(t, in, out, true) +} + +func TestStringTypeAnnotationDetected(t *testing.T) { + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretType: string(SecretTypeString), + }, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifyStringSecret(t, in, out, true) +} + +func TestStringLengthFromAnnotation(t *testing.T) { + in := newStringTestSecret("testfield", map[string]string{ + AnnotationSecretType: string(SecretTypeString), + AnnotationSecretLength: "42", + }, "") + require.NoError(t, mgr.GetClient().Create(context.TODO(), in)) + + doReconcile(t, in, false) + + out := &corev1.Secret{} + require.NoError(t, mgr.GetClient().Get(context.TODO(), types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace}, out)) + verifyStringSecret(t, in, out, true) + if len(out.Data["testfield"]) != 42 { + t.Error("mismatch between secret length and annotation") + } +} + +func TestGeneratedSecretsHaveCorrectLength(t *testing.T) { + pwd, err := generateRandomString(20) + + t.Log("generated", pwd) + + if err != nil { + t.Error(err) + } + + if len(pwd) != 20 { + t.Error("string length", "expected", 20, "got", len(pwd)) + } +} + +func TestGeneratedSecretsAreRandom(t *testing.T) { + one, errOne := generateRandomString(32) + two, errTwo := generateRandomString(32) + + if errOne != nil { + t.Error(errOne) + } + if errTwo != nil { + t.Error(errTwo) + } + + if one == two { + t.Error("string equality", "got", one) + } +} + +func BenchmarkGenerateSecret(b *testing.B) { + for i := 0; i < b.N; i++ { + _, err := generateRandomString(32) + if err != nil { + b.Error(err) + } + } +} diff --git a/pkg/controller/secret/types.go b/pkg/controller/secret/types.go new file mode 100644 index 00000000..2930759b --- /dev/null +++ b/pkg/controller/secret/types.go @@ -0,0 +1,36 @@ +package secret + +import ( + "fmt" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + AnnotationSecretAutoGenerate = "secret-generator.v1.mittwald.de/autogenerate" + AnnotationSecretAutoGeneratedAt = "secret-generator.v1.mittwald.de/autogenerate-generated-at" + AnnotationSecretRegenerate = "secret-generator.v1.mittwald.de/regenerate" + AnnotationSecretSecure = "secret-generator.v1.mittwald.de/secure" + AnnotationSecretType = "secret-generator.v1.mittwald.de/type" + AnnotationSecretLength = "secret-generator.v1.mittwald.de/length" +) + +type SecretType string + +const ( + SecretTypeString SecretType = "string" + SecretTypeSSHKeypair SecretType = "ssh-keypair" +) + +func (st SecretType) Validate() error { + switch st { + case SecretTypeString, + SecretTypeSSHKeypair: + return nil + } + return fmt.Errorf("%s is not a valid secret type", st) +} + +type SecretGenerator interface { + generateData(*corev1.Secret) (reconcile.Result, error) +}