package couchdb import ( "bytes" "context" "crypto/rand" "crypto/sha1" // nolint:gosec // SHA1 PRF for PBKDF2-HMAC-SHA1 "encoding/hex" "errors" "fmt" "math/big" "regexp" "golang.org/x/crypto/pbkdf2" corev1 "k8s.io/api/core/v1" k8errors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) const ( // secret data keys SecretUsername = "username" SecretPassword = "password" SecretAdminsIni = "admins.ini" SecretURI = "uri" SecretDBName = "dbname" SecretCookieName = "cookieAuthSecret" // secret names AdminSecretName = "couchdb-admin-creds" //nolint:gosec // default admin secret name CookieSecretName = "couchdb-cookie" //nolint:gosec // default cookie secret name ReplicationSMgrSecretName = "store-couchdb-repl" //nolint:gosec // secret name only StoreReplicationSecretName = "couchdb-master-creds" //nolint:gosec // secret name only StoreSecretName = "couchdb-local-creds" //nolint:gosec // secret name only ReplicationSecretName = "couchdb-replication-creds" //nolint:gosec // secret name only ) var ( ErrInvalidCredentialsSecret = errors.New("the credentials secret has missing values") ErrInvalidCookieSecret = errors.New("the cookie secret has missing values") ErrCouchDBURIMissing = errors.New("the secret is missing couchdb URI") ErrDBNameMissing = errors.New("the secret is missing DB Name") ErrSecretDataMissing = errors.New("field missing from secret") // PBKDF2-HMAC-SHA1 with 20-byte keys, output size is deterministic, lazily verify username part adminRegexStr = `^\[admins\]\n.* = -pbkdf2-[a-z0-9]{40},[a-z0-9]{16},4096\n$` adminRegex = regexp.MustCompile(adminRegexStr) ) // CredentialsManager generate and retrieves couchdb related secret type CredentialsManager interface { FromSecret(ctx context.Context, cl client.Client, nn client.ObjectKey) (*corev1.Secret, error) ToSecret(ctx context.Context, cl client.Client, nn client.ObjectKey, ownerRefs ...metav1.OwnerReference) (*corev1.Secret, error) } type UsernamePassword struct { Username []byte Password []byte } // AdminCredentials admin credentials for couchdb server type AdminCredentials struct { UsernamePassword CookieAuthSecret []byte PrehashedAdmins []byte } // UserCredentials a user with limited permission to be used by workload team, etc... type UserCredentials struct { UsernamePassword URI []byte // URL for couchdb server } type ReplicationCredentials struct { UserCredentials DBName []byte // replication database i.e `repl-hash` } // SecretManagerSecret is the string encoded version of SecretManagerSecret. // json.Marshall encodes []bytes as base64 encoded strings, so a string representation is required // to interoperate. type SecretManagerSecret struct { Username string `json:"username"` Password string `json:"password"` URI string `json:"uri"` DBName string `json:"dbname"` } // GenerateUsernamePassword random username and password func (l *UsernamePassword) GenerateUsernamePassword() { if len(l.Username) == 0 { l.Username = randStr(8) } if len(l.Password) == 0 { l.Password = randStr(16) } } // Parse common secret fields parsing for UsernamePassword func (l *UsernamePassword) Parse(secret *corev1.Secret) error { // validate the secret data e := fmt.Errorf("%w", ErrInvalidCredentialsSecret) username, ok := secret.Data[SecretUsername] if !ok || len(username) == 0 { return fmt.Errorf("%w. missing %s", e, SecretUsername) } l.Username = username password, ok := secret.Data[SecretPassword] if !ok || len(password) == 0 { return fmt.Errorf("%w. missing %s", e, SecretPassword) } l.Password = password return nil } // FromSecret based for all couchdb secrets: func (l *UsernamePassword) FromSecret(ctx context.Context, cl client.Client, nn client.ObjectKey) (*corev1.Secret, error) { secret := &corev1.Secret{} err := cl.Get(ctx, nn, secret) if err != nil { return nil, err } return secret, l.Parse(secret) } // Parse common secret fields parsing for UserCredentials func (l *UserCredentials) Parse(secret *corev1.Secret) error { if err := l.UsernamePassword.Parse(secret); err != nil { return err } uri, ok := secret.Data[SecretURI] if !ok || len(uri) == 0 { return fmt.Errorf("%w. missing %s", ErrCouchDBURIMissing, SecretURI) } if len(l.URI) != 0 && !bytes.Equal(l.URI, uri) { return fmt.Errorf("%s: %w", SecretURI, ErrSecretDataMissing) } l.URI = uri return nil } func (c *AdminCredentials) FromSecret(ctx context.Context, cl client.Client, nn client.ObjectKey) (*corev1.Secret, error) { // try to get the secret secret := &corev1.Secret{} err := cl.Get(ctx, nn, secret) if err != nil { return nil, err } err = c.UsernamePassword.Parse(secret) if err != nil { return nil, err } phaData, ok := secret.Data[SecretAdminsIni] if !ok || len(phaData) == 0 { hashed, err := pbkdf2Hash(c.Password) if err != nil { return nil, fmt.Errorf("failed to hash admin creds from secret: %v. err: %w", nn, err) } phaData = toAdminIni(string(c.Username), hashed) } match := adminRegex.Match(phaData) if !match { return nil, fmt.Errorf("%w. invalid %s, must match %s", ErrInvalidCredentialsSecret, SecretAdminsIni, adminRegexStr) } c.PrehashedAdmins = phaData e := fmt.Errorf("%w", ErrInvalidCookieSecret) data, ok := secret.Data[SecretCookieName] if !ok || len(data) == 0 { return nil, e } c.CookieAuthSecret = data return secret, nil } // ToSecret attempts to create a new credentials secret with generated strings func (c *AdminCredentials) ToSecret(ctx context.Context, cl client.Client, nn client.ObjectKey, ownerRefs ...metav1.OwnerReference) (*corev1.Secret, error) { c.GenerateUsernamePassword() // generate hash if missing if len(c.PrehashedAdmins) < 1 { hashed, err := pbkdf2Hash(c.Password) if err != nil { return nil, fmt.Errorf("failed to generate prehashed admin secret for credential: %v. err: %v", nn, err) } c.PrehashedAdmins = toAdminIni(string(c.Username), hashed) } if len(c.CookieAuthSecret) < 1 { cookieData, err := getCookie(ctx, cl) if err != nil { return nil, fmt.Errorf("failed to get/generate cookie: err: %v", err) } c.CookieAuthSecret = cookieData } secret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", Kind: "Secret", }, ObjectMeta: metav1.ObjectMeta{ Name: nn.Name, Namespace: nn.Namespace, Labels: map[string]string{ IgnoreDeletionLabel: "true", }, }, Data: map[string][]byte{ SecretUsername: c.Username, SecretPassword: c.Password, SecretAdminsIni: c.PrehashedAdmins, SecretCookieName: c.CookieAuthSecret, }, } if len(ownerRefs) > 0 { secret.OwnerReferences = ownerRefs } return secret, nil } func (c *ReplicationCredentials) FromSecret(ctx context.Context, client client.Client, nn client.ObjectKey) (*corev1.Secret, error) { // try to get the secret secret := &corev1.Secret{} err := client.Get(ctx, nn, secret) if err != nil { return nil, err } if err = c.UserCredentials.Parse(secret); err != nil { return nil, err } data, ok := secret.Data[SecretDBName] if !ok || len(data) == 0 { // TODO handle backward compatibility return nil, ErrDBNameMissing } c.DBName = data return secret, nil } // ToSecret attempts to create a new credentials secret with generated strings func (c *ReplicationCredentials) ToSecret(_ context.Context, _ client.Client, nn client.ObjectKey, ownerRefs ...metav1.OwnerReference) (*corev1.Secret, error) { e := fmt.Errorf("%w", ErrInvalidCredentialsSecret) if c.DBName == nil { return nil, fmt.Errorf("%w. missing %s", e, SecretDBName) } if c.URI == nil { return nil, fmt.Errorf("%w. missing %s", e, SecretURI) } c.GenerateUsernamePassword() secret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", Kind: "Secret", }, ObjectMeta: metav1.ObjectMeta{ Name: nn.Name, Namespace: nn.Namespace, }, Data: map[string][]byte{ SecretUsername: c.Username, SecretPassword: c.Password, SecretURI: c.URI, SecretDBName: c.DBName, }, } if len(ownerRefs) > 0 { secret.OwnerReferences = ownerRefs } return secret, nil } func (l *UserCredentials) FromSecret(ctx context.Context, client client.Client, nn client.ObjectKey) (*corev1.Secret, error) { secret := &corev1.Secret{} if err := client.Get(ctx, nn, secret); err != nil { return nil, err } if err := l.Parse(secret); err != nil { return nil, err } return secret, nil } func (l *UserCredentials) ToSecret(_ context.Context, _ client.Client, nn client.ObjectKey, ownerRefs ...metav1.OwnerReference) (*corev1.Secret, error) { l.GenerateUsernamePassword() if len(l.URI) == 0 { return nil, ErrCouchDBURIMissing } secret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", Kind: "Secret", }, ObjectMeta: metav1.ObjectMeta{ Name: nn.Name, Namespace: nn.Namespace, }, Data: map[string][]byte{ SecretUsername: l.Username, SecretPassword: l.Password, SecretURI: l.URI, }, } if len(ownerRefs) > 0 { secret.OwnerReferences = ownerRefs } return secret, nil } func getCookie(ctx context.Context, cl client.Client) ([]byte, error) { cookieSecret := &corev1.Secret{} cookieKey := client.ObjectKey{Namespace: Namespace, Name: CookieSecretName} var cookieData []byte if err := cl.Get(ctx, cookieKey, cookieSecret); err != nil { if !k8errors.IsNotFound(err) { return nil, err } // generate new cookie secret if secret not found cookieData = randStr(32) } else { ok := false cookieData, ok = cookieSecret.Data[SecretCookieName] if !ok || len(cookieData) == 0 { // generate new cookie secret if secret is empty cookieData = randStr(32) } } return cookieData, nil } func randStr(length int) []byte { letterBytes := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" b := make([]byte, length) l := int64(len(letterBytes)) for i := range b { nBig, err := rand.Int(rand.Reader, big.NewInt(l)) if err != nil { panic(err) } n := nBig.Int64() b[i] = letterBytes[n] } return b } func pbkdf2Hash(password []byte) (string, error) { salt := make([]byte, 8) n, err := rand.Read(salt) if err != nil { return "", fmt.Errorf("failed to generate random salt: %v", err) } if n != 8 { return "", fmt.Errorf("pbkdf2 err: expected 8 byte salt, was: %v", n) } salt8 := hex.EncodeToString(salt) iter := 4096 // couchdb uses PBKDF2-HMAC-SHA1 with 20-byte keys key := pbkdf2.Key(password, []byte(salt8), iter, 20, sha1.New) return fmt.Sprintf("-pbkdf2-%x,%s,%d", key, salt8, iter), nil } func toAdminIni(user, hashed string) []byte { return []byte(fmt.Sprintf("[admins]\n%s = %s\n", user, hashed)) }