diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index e876df6c0..890dd99b1 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -18,6 +18,8 @@ package engine import ( "bytes" + "crypto/sha1" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -31,6 +33,7 @@ import ( "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" ) @@ -67,6 +70,7 @@ func funcMap() template.FuncMap { "mustToJson": mustToJSON, "fromJson": fromJSON, "fromJsonArray": fromJSONArray, + "htpasswd": htpasswd, // Duration helpers "mustToDuration": mustToDuration, @@ -99,6 +103,34 @@ func funcMap() template.FuncMap { return f } +// htpasswd generates an Apache htpasswd entry for username and password. +// algorithm is optional and defaults to bcrypt. Supported values are +// "bcrypt", "sha", and "sha1". +func htpasswd(username, password string, algorithm ...string) string { + if strings.Contains(username, ":") { + return "invalid username: " + username + } + + algo := "bcrypt" + if len(algorithm) > 0 && algorithm[0] != "" { + algo = algorithm[0] + } + + switch algo { + case "bcrypt": + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "failed to encrypt string with bcrypt: " + err.Error() + } + return username + ":" + string(hash) + case "sha", "sha1": + sum := sha1.Sum([]byte(password)) + return username + ":{SHA}" + base64.StdEncoding.EncodeToString(sum[:]) + default: + return "invalid algorithm: " + algo + } +} + // 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 03ed64153..b903ed626 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -17,6 +17,8 @@ limitations under the License. package engine import ( + "crypto/sha1" + "encoding/base64" "math" "strings" "testing" @@ -195,6 +197,59 @@ keyInElement1 = "valueInElement1"`, } } +func TestHtpasswd(t *testing.T) { + const username = "testuser" + const password = "testpassword" + + shaSum := sha1.Sum([]byte(password)) + shaExpected := username + ":{SHA}" + base64.StdEncoding.EncodeToString(shaSum[:]) + + tests := []struct { + name string + tpl string + expect string + bcrypt bool + }{{ + name: "two-arg defaults to bcrypt", + tpl: `{{ htpasswd "testuser" "testpassword" }}`, + bcrypt: true, + }, { + name: "three-arg bcrypt", + tpl: `{{ htpasswd "testuser" "testpassword" "bcrypt" }}`, + bcrypt: true, + }, { + name: "three-arg sha", + tpl: `{{ htpasswd "testuser" "testpassword" "sha" }}`, + expect: shaExpected, + }, { + name: "three-arg sha1", + tpl: `{{ htpasswd "testuser" "testpassword" "sha1" }}`, + expect: shaExpected, + }, { + name: "invalid algorithm", + tpl: `{{ htpasswd "testuser" "testpassword" "md5" }}`, + expect: "invalid algorithm: md5", + }, { + name: "username containing colon", + tpl: `{{ htpasswd "user:name" "testpassword" }}`, + expect: "invalid username: user:name", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + require.NoError(t, template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, nil), tt.tpl) + got := b.String() + if tt.bcrypt { + assert.True(t, strings.HasPrefix(got, username+":$")) + assert.NotContains(t, got, "{SHA}") + return + } + assert.Equal(t, tt.expect, got, tt.tpl) + }) + } +} + func TestDurationHelpers(t *testing.T) { tests := []struct { name string