diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index e03c13b38..d95541021 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -18,13 +18,17 @@ package engine import ( "bytes" + "crypto/sha1" + "encoding/base64" "encoding/json" + "fmt" "maps" "strings" "text/template" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" + "golang.org/x/crypto/bcrypt" "sigs.k8s.io/yaml" goYaml "sigs.k8s.io/yaml/goyaml.v3" ) @@ -49,6 +53,7 @@ func funcMap() template.FuncMap { // Add some extra functionality extra := template.FuncMap{ + "htpasswd": htpasswd, "toToml": toTOML, "mustToToml": mustToTOML, "fromToml": fromTOML, @@ -80,6 +85,43 @@ func funcMap() template.FuncMap { return f } +// htpasswd takes a username and password and returns an htpasswd entry. +// By default it uses bcrypt, matching Sprig's existing behavior. +// An optional third argument can explicitly select the hash algorithm. +func htpasswd(username, password string, hashAlgorithms ...string) (string, error) { + if strings.Contains(username, ":") { + return fmt.Sprintf("invalid username: %s", username), nil + } + + if len(hashAlgorithms) > 1 { + return "", fmt.Errorf("wrong number of args for htpasswd: want 2 or 3 got %d", len(hashAlgorithms)+2) + } + + algorithm := "bcrypt" + if len(hashAlgorithms) == 1 && hashAlgorithms[0] != "" { + algorithm = strings.ToLower(hashAlgorithms[0]) + } + + switch algorithm { + case "bcrypt": + return fmt.Sprintf("%s:%s", username, sprigBcrypt(password)), nil + case "sha", "sha1": + sum := sha1.Sum([]byte(password)) + return fmt.Sprintf("%s:{SHA}%s", username, base64.StdEncoding.EncodeToString(sum[:])), nil + default: + return "", fmt.Errorf("unsupported htpasswd hash algorithm %q", hashAlgorithms[0]) + } +} + +func sprigBcrypt(input string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(input), bcrypt.DefaultCost) + if err != nil { + return fmt.Sprintf("failed to encrypt string with bcrypt: %s", err) + } + + return string(hash) +} + // toYAML takes an interface, marshals it to yaml, and returns a string. It will // always return a string, even on marshal error (empty string). // diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index be9d0153f..36c4d9cb4 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -22,6 +22,8 @@ import ( "text/template" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" ) func TestFuncs(t *testing.T) { @@ -181,6 +183,74 @@ keyInElement1 = "valueInElement1"`, } } +func TestHtpasswd(t *testing.T) { + tests := []struct { + name string + tpl string + expect string + expectError string + validate func(t *testing.T, rendered string) + }{ + { + name: "defaults to bcrypt", + tpl: `{{ htpasswd "testuser" "testpassword" }}`, + validate: func(t *testing.T, rendered string) { + t.Helper() + parts := strings.SplitN(rendered, ":", 2) + require.Len(t, parts, 2) + assert.Equal(t, "testuser", parts[0]) + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(parts[1]), []byte("testpassword"))) + }, + }, + { + name: "supports explicit bcrypt algorithm", + tpl: `{{ htpasswd "testuser" "testpassword" "bcrypt" }}`, + validate: func(t *testing.T, rendered string) { + t.Helper() + parts := strings.SplitN(rendered, ":", 2) + require.Len(t, parts, 2) + assert.Equal(t, "testuser", parts[0]) + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(parts[1]), []byte("testpassword"))) + }, + }, + { + name: "supports sha algorithm", + tpl: `{{ htpasswd "testuser" "testpassword" "sha" }}`, + expect: `testuser:{SHA}i7YRj4/Wk1rQh2o740pxfTJwj/0=`, + }, + { + name: "preserves invalid username behavior", + tpl: `{{ htpasswd "bad:user" "testpassword" }}`, + expect: `invalid username: bad:user`, + }, + { + name: "rejects unsupported algorithms", + tpl: `{{ htpasswd "testuser" "testpassword" "md5" }}`, + expectError: `unsupported htpasswd hash algorithm "md5"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, nil) + if tt.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectError) + return + } + + require.NoError(t, err) + if tt.validate != nil { + tt.validate(t, b.String()) + return + } + + assert.Equal(t, tt.expect, b.String()) + }) + } +} + // This test to check a function provided by sprig is due to a change in a // dependency of sprig. mergo in v0.3.9 changed the way it merges and only does // public fields (i.e. those starting with a capital letter). This test, from