From 45e4346726bd86d56cb0bb84093979d3f76dbcf6 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Mon, 9 Jun 2025 23:32:19 -0400 Subject: [PATCH 01/11] Add optional `FAKTORY_WEBUI_PASSWORD` with bcrypt2 hash support --- cli/cli.go | 53 ++++++++++++++++++++++++------ cli/security_test.go | 14 ++++---- go.mod | 1 + go.sum | 2 ++ password/password.go | 78 ++++++++++++++++++++++++++++++++++++++++++++ server/config.go | 5 ++- webui/web.go | 16 ++++++--- 7 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 password/password.go diff --git a/cli/cli.go b/cli/cli.go index bf54fb3..4021c75 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -27,6 +27,13 @@ type CliOptions struct { StorageDirectory string } +type passwordType string + +const ( + passwordTypeWebUI passwordType = "webui" + passwordTypeServer passwordType = "server" +) + func ParseArguments() CliOptions { fenv := os.Getenv("FAKTORY_ENV") @@ -146,10 +153,14 @@ func BuildServer(opts *CliOptions) (*server.Server, func() error, error) { return nil, nil, err } - pwd, err := fetchPassword(globalConfig, opts.Environment) + pwd, err := fetchPassword(globalConfig, opts.Environment, passwordTypeServer) if err != nil { return nil, nil, err } + webPwd, err := fetchPassword(globalConfig, opts.Environment, passwordTypeWebUI) + if err != nil { + util.Warnf("unable to fetch Web UI password, defaulting to server's: %v", err) + } sock := fmt.Sprintf("%s/redis.sock", opts.StorageDirectory) stopper, err := storage.Boot(opts.StorageDirectory, sock) @@ -172,6 +183,7 @@ func BuildServer(opts *CliOptions) (*server.Server, func() error, error) { RedisSock: sock, GlobalConfig: globalConfig, Password: pwd, + WebUIPassword: webPwd, PoolSize: server.DefaultMaxPoolSize, } @@ -247,33 +259,54 @@ func readConfig(cdir string, env string) (map[string]any, error) { // [faktory] // password = "foobar" # or... // password = "/run/secrets/my_faktory_password" -func fetchPassword(cfg map[string]any, env string) (string, error) { +// +// [web] +// password = "foobar" # or... +// password = "/run/secrets/my_faktory_password" +func fetchPassword(cfg map[string]any, env string, pwdtype passwordType) (string, error) { password := "" + var envKey string + var pwdPath string + cfgPath := struct { + Subsys string + Elm string + }{} + if pwdtype == passwordTypeServer { + envKey = "FAKTORY_PASSWORD" + pwdPath = "/etc/faktory/password" + cfgPath.Subsys = "faktory" + cfgPath.Elm = "password" + } else { + envKey = "FAKTORY_WEBUI_PASSWORD" + pwdPath = "/etc/faktory/webui_password" + cfgPath.Subsys = "web" + cfgPath.Elm = "password" + } + // allow the password to be injected via ENV rather than committed // to filesystem. Note if this value starts with a /, then it is // considered a pointer to a file on the filesystem with the password // value, e.g. FAKTORY_PASSWORD=/run/secrets/my_faktory_password. - val, ok := os.LookupEnv("FAKTORY_PASSWORD") + val, ok := os.LookupEnv(envKey) if ok { password = val } else { - val := stringConfig(cfg, "faktory", "password", "") + val := stringConfig(cfg, cfgPath.Subsys, cfgPath.Elm, "") if val != "" { password = val // clear password so we can log it safely - x := cfg["faktory"].(map[string]any) - x["password"] = "********" + x := cfg[cfgPath.Subsys].(map[string]any) + x[cfgPath.Elm] = "********" } } if env != "development" && !skip() && password == "" { - ok, _ := util.FileExists("/etc/faktory/password") + ok, _ := util.FileExists(pwdPath) if ok { - //nolint:gosec - password = "/etc/faktory/password" + password = pwdPath } } @@ -288,7 +321,7 @@ func fetchPassword(cfg map[string]any, env string) (string, error) { password = strings.TrimSpace(string(data)) } - if env != "development" && !skip() && password == "" { + if env != "development" && !skip() && password == "" && pwdtype == passwordTypeServer { return "", fmt.Errorf("faktory requires a password to be set in staging or production, see the Security wiki page") } diff --git a/cli/security_test.go b/cli/security_test.go index 42779b9..d84c4f6 100644 --- a/cli/security_test.go +++ b/cli/security_test.go @@ -22,7 +22,7 @@ func TestPasswords(t *testing.T) { t.Run("DevWithPassword", func(t *testing.T) { cfg := pwdCfg(pwd) - pwd, err := fetchPassword(cfg, "development") + pwd, err := fetchPassword(cfg, "development", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, 16, len(pwd)) assert.Equal(t, "cce29d6565ab7376", pwd) @@ -30,13 +30,13 @@ func TestPasswords(t *testing.T) { }) t.Run("DevWithoutPassword", func(t *testing.T) { - pwd, err := fetchPassword(emptyCfg, "development") + pwd, err := fetchPassword(emptyCfg, "development", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "", pwd) }) t.Run("ProductionWithoutPassword", func(t *testing.T) { - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.Error(t, err) assert.Equal(t, "", pwd) }) @@ -46,14 +46,14 @@ func TestPasswords(t *testing.T) { err := os.WriteFile("/tmp/test-password", []byte("foobar"), os.FileMode(0o666)) assert.NoError(t, err) cfg := pwdCfg("/tmp/test-password") - pwd, err := fetchPassword(cfg, "production") + pwd, err := fetchPassword(cfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "foobar", pwd) }) t.Run("ProductionWithPassword", func(t *testing.T) { cfg := pwdCfg(pwd) - pwd, err := fetchPassword(cfg, "production") + pwd, err := fetchPassword(cfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, 16, len(pwd)) assert.Equal(t, "cce29d6565ab7376", pwd) @@ -63,7 +63,7 @@ func TestPasswords(t *testing.T) { t.Run("ProductionEnvPassword", func(t *testing.T) { os.Setenv("FAKTORY_PASSWORD", "abc123") - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "abc123", pwd) }) @@ -73,7 +73,7 @@ func TestPasswords(t *testing.T) { t.Run("ProductionSkipPassword", func(t *testing.T) { os.Setenv("FAKTORY_SKIP_PASSWORD", "yes") - pwd, err := fetchPassword(emptyCfg, "production") + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeServer) assert.NoError(t, err) assert.Equal(t, "", pwd) }) diff --git a/go.mod b/go.mod index 3ef478f..8e0ef53 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.39.0 // indirect ) require ( diff --git a/go.sum b/go.sum index aaf9968..7a6f4b0 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/password/password.go b/password/password.go new file mode 100644 index 0000000..6d229a1 --- /dev/null +++ b/password/password.go @@ -0,0 +1,78 @@ +package password + +import ( + "crypto/subtle" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type hashID string + +const ( + hashIDBCrypt2 hashID = "2" // technically a major ver only +) + +type HashAlgorithmType string + +const ( + HashAlgorithmTypeBCrypt HashAlgorithmType = "bcrypt" + HashAlgorithmTypeArgon HashAlgorithmType = "argon" + HashAlgorithmTypeUnknown HashAlgorithmType = "" +) + +func Verify(candidate string, configured string) (bool, error) { + if isSupportedPasswordHash(configured) { + return verifyAgainstHash(candidate, configured) + } else { + return verifyAgainstPlaintext(candidate, configured), nil + } +} + +func verifyAgainstHash(password string, hashedPassword string) (bool, error) { + algo, err := detectHashAlgorithm(hashedPassword) + if err != nil { + return false, err + } + if algo == HashAlgorithmTypeBCrypt { + err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + if err != nil { + return false, err + } else { + return true, nil + } + } + panic(fmt.Sprintf("Password hash algorithm not implemented: %s", algo)) +} + +func verifyAgainstPlaintext(pwd1 string, pwd2 string) bool { + return subtle.ConstantTimeCompare([]byte(pwd1), []byte(pwd2)) != 1 +} + +// isSupportedPasswordHash returns true if the supplied password is actually a +// hash as defined by the PHC format that Faktory supports. Per OWASP guidance, +// only Argon2id, scrypt, bcrypt, and PBKDF2 hashes are supported. +// +// https://github.com/P-H-C/phc-string-format +// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html +func isSupportedPasswordHash(pwd string) bool { + algo, err := detectHashAlgorithm(pwd) + if err != nil { + return false + } + return algo != HashAlgorithmTypeUnknown +} + +func detectHashAlgorithm(pwd string) (HashAlgorithmType, error) { + // TODO: do a fulsome parsing of PHC format + parts := strings.Split(pwd, "$") + if parts[0] != "" || len(parts) < 2 || len(parts[1]) < 1 { + return HashAlgorithmTypeUnknown, errors.New("not a recognizable password hash format") + } + if hashID(parts[1][0]) == hashIDBCrypt2 { + return HashAlgorithmTypeBCrypt, nil + } + return HashAlgorithmTypeUnknown, fmt.Errorf("unknown password hash algorithm id %s", parts[1]) +} diff --git a/server/config.go b/server/config.go index 3287a8b..01e8f78 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,8 @@ package server -import "github.com/contribsys/faktory/util" +import ( + "github.com/contribsys/faktory/util" +) // This is the ultimate scalability limitation in Faktory, // we only allow this many connections to Redis. @@ -14,6 +16,7 @@ type ServerOptions struct { ConfigDirectory string Environment string Password string + WebUIPassword string PoolSize uint64 } diff --git a/webui/web.go b/webui/web.go index c332a11..5365653 100644 --- a/webui/web.go +++ b/webui/web.go @@ -2,7 +2,6 @@ package webui import ( "context" - "crypto/subtle" "embed" "encoding/json" "fmt" @@ -14,6 +13,7 @@ import ( "time" "github.com/contribsys/faktory/client" + "github.com/contribsys/faktory/password" "github.com/contribsys/faktory/server" "github.com/contribsys/faktory/util" "github.com/justinas/nosurf" @@ -205,8 +205,9 @@ func (l *Lifecycle) opts(s *server.Server) Options { } // Allow the Web UI to have a different password from the command port // so you can rotate user-used passwords and machine-used passwords separately - pwd := s.Options.String("web", "password", "") + pwd := s.Options.WebUIPassword if pwd == "" { + util.Info("Defaulting Web UI password to server's") pwd = s.Options.Password } opts.Password = pwd @@ -324,15 +325,20 @@ func setup(ui *WebUI, pass http.HandlerFunc, debug bool) http.HandlerFunc { return genericSetup } -func basicAuth(pwd string, pass http.HandlerFunc) http.HandlerFunc { +func basicAuth(srvPwd string, pass http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _, password, ok := r.BasicAuth() + _, pwd, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="Faktory"`) http.Error(w, "Authorization required", http.StatusUnauthorized) return } - if subtle.ConstantTimeCompare([]byte(password), []byte(pwd)) != 1 { + // Web UI's configured password is actually a hash. + verified, err := password.Verify(pwd, srvPwd) + if !verified { + if err != nil { + util.Error("Failed password verification", err) + } w.Header().Set("WWW-Authenticate", `Basic realm="Faktory"`) http.Error(w, "Authorization failed", http.StatusUnauthorized) return From 509551ca00c8c7782d4f9eb32036f63db4d03372 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Mon, 9 Jun 2025 23:49:44 -0400 Subject: [PATCH 02/11] Only export Verify func for now --- password/password.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/password/password.go b/password/password.go index 6d229a1..829c00b 100644 --- a/password/password.go +++ b/password/password.go @@ -15,12 +15,12 @@ const ( hashIDBCrypt2 hashID = "2" // technically a major ver only ) -type HashAlgorithmType string +type hashAlgorithmType string const ( - HashAlgorithmTypeBCrypt HashAlgorithmType = "bcrypt" - HashAlgorithmTypeArgon HashAlgorithmType = "argon" - HashAlgorithmTypeUnknown HashAlgorithmType = "" + hashAlgorithmTypeBCrypt hashAlgorithmType = "bcrypt" + hashAlgorithmTypeArgon hashAlgorithmType = "argon" + hashAlgorithmTypeUnknown hashAlgorithmType = "" ) func Verify(candidate string, configured string) (bool, error) { @@ -36,7 +36,7 @@ func verifyAgainstHash(password string, hashedPassword string) (bool, error) { if err != nil { return false, err } - if algo == HashAlgorithmTypeBCrypt { + if algo == hashAlgorithmTypeBCrypt { err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) if err != nil { return false, err @@ -62,17 +62,17 @@ func isSupportedPasswordHash(pwd string) bool { if err != nil { return false } - return algo != HashAlgorithmTypeUnknown + return algo != hashAlgorithmTypeUnknown } -func detectHashAlgorithm(pwd string) (HashAlgorithmType, error) { +func detectHashAlgorithm(pwd string) (hashAlgorithmType, error) { // TODO: do a fulsome parsing of PHC format parts := strings.Split(pwd, "$") if parts[0] != "" || len(parts) < 2 || len(parts[1]) < 1 { - return HashAlgorithmTypeUnknown, errors.New("not a recognizable password hash format") + return hashAlgorithmTypeUnknown, errors.New("not a recognizable password hash format") } if hashID(parts[1][0]) == hashIDBCrypt2 { - return HashAlgorithmTypeBCrypt, nil + return hashAlgorithmTypeBCrypt, nil } - return HashAlgorithmTypeUnknown, fmt.Errorf("unknown password hash algorithm id %s", parts[1]) + return hashAlgorithmTypeUnknown, fmt.Errorf("unknown password hash algorithm id %s", parts[1]) } From 3c9f3c3455107d2691c6f25d2c91a67d2766f788 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 00:22:54 -0400 Subject: [PATCH 03/11] Fix plaintext password check mistake --- password/password.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/password/password.go b/password/password.go index 829c00b..11a6ccd 100644 --- a/password/password.go +++ b/password/password.go @@ -48,7 +48,7 @@ func verifyAgainstHash(password string, hashedPassword string) (bool, error) { } func verifyAgainstPlaintext(pwd1 string, pwd2 string) bool { - return subtle.ConstantTimeCompare([]byte(pwd1), []byte(pwd2)) != 1 + return subtle.ConstantTimeCompare([]byte(pwd1), []byte(pwd2)) == 1 } // isSupportedPasswordHash returns true if the supplied password is actually a From bada3c16dfa609d20b01ca43c5fe89457b22a646 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 00:24:16 -0400 Subject: [PATCH 04/11] Add Argon2id support --- password/password.go | 50 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/password/password.go b/password/password.go index 11a6ccd..0582ae0 100644 --- a/password/password.go +++ b/password/password.go @@ -2,25 +2,28 @@ package password import ( "crypto/subtle" + "encoding/base64" "errors" "fmt" "strings" + "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) type hashID string const ( - hashIDBCrypt2 hashID = "2" // technically a major ver only + hashIDBCrypt2 hashID = "2" // technically a major ver only + hashIDArgon2id hashID = "argon2id" ) type hashAlgorithmType string const ( - hashAlgorithmTypeBCrypt hashAlgorithmType = "bcrypt" - hashAlgorithmTypeArgon hashAlgorithmType = "argon" - hashAlgorithmTypeUnknown hashAlgorithmType = "" + hashAlgorithmTypeBCrypt hashAlgorithmType = "bcrypt" + hashAlgorithmTypeArgon2id hashAlgorithmType = "argon2id" + hashAlgorithmTypeUnknown hashAlgorithmType = "" ) func Verify(candidate string, configured string) (bool, error) { @@ -43,6 +46,40 @@ func verifyAgainstHash(password string, hashedPassword string) (bool, error) { } else { return true, nil } + } else if algo == hashAlgorithmTypeArgon2id { + var ver int + parts := strings.Split(hashedPassword, "$") + _, err = fmt.Sscanf(parts[2], "v=%d", &ver) + if ver != argon2.Version { + return false, fmt.Errorf("Password hash uses incompatible version of Argon2id (want %d, given %d)", argon2.Version, ver) + } + // TODO: These are technically optional. Use defaults if absent + var mem uint32 + var iter uint32 + var para uint8 + _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &iter, ¶) + if err != nil { + return false, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return false, err + } + key, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil { + return false, err + } + keylen := int32(len(key)) + candidateKey := argon2.IDKey([]byte(password), salt, iter, mem, para, uint32(keylen)) + candidateKeylen := int32(len(candidateKey)) + + if subtle.ConstantTimeEq(keylen, candidateKeylen) == 0 { + return false, nil + } + if subtle.ConstantTimeCompare(key, candidateKey) == 1 { + return true, nil + } + return false, nil } panic(fmt.Sprintf("Password hash algorithm not implemented: %s", algo)) } @@ -65,6 +102,8 @@ func isSupportedPasswordHash(pwd string) bool { return algo != hashAlgorithmTypeUnknown } +// TODO: return a genericish struct/interface or something so we don't +// wastefully keep parsing the string over and over func detectHashAlgorithm(pwd string) (hashAlgorithmType, error) { // TODO: do a fulsome parsing of PHC format parts := strings.Split(pwd, "$") @@ -74,5 +113,8 @@ func detectHashAlgorithm(pwd string) (hashAlgorithmType, error) { if hashID(parts[1][0]) == hashIDBCrypt2 { return hashAlgorithmTypeBCrypt, nil } + if hashID(parts[1]) == hashIDArgon2id { + return hashAlgorithmTypeArgon2id, nil + } return hashAlgorithmTypeUnknown, fmt.Errorf("unknown password hash algorithm id %s", parts[1]) } From 9151345d6bb090c76b9b5d24bf7fe91b8389db4d Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 00:56:17 -0400 Subject: [PATCH 05/11] Refactor into per-algo structs with Verify interface --- password/password.go | 154 ++++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/password/password.go b/password/password.go index 0582ae0..130200a 100644 --- a/password/password.go +++ b/password/password.go @@ -3,10 +3,10 @@ package password import ( "crypto/subtle" "encoding/base64" - "errors" "fmt" "strings" + "github.com/contribsys/faktory/util" "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) @@ -18,103 +18,105 @@ const ( hashIDArgon2id hashID = "argon2id" ) -type hashAlgorithmType string +type PasswordType interface { + Verify(candidate string) (bool, error) +} -const ( - hashAlgorithmTypeBCrypt hashAlgorithmType = "bcrypt" - hashAlgorithmTypeArgon2id hashAlgorithmType = "argon2id" - hashAlgorithmTypeUnknown hashAlgorithmType = "" -) +type basePasswordType struct { + Hashed string +} +type plainPasswordType struct { + basePasswordType +} -func Verify(candidate string, configured string) (bool, error) { - if isSupportedPasswordHash(configured) { - return verifyAgainstHash(candidate, configured) - } else { - return verifyAgainstPlaintext(candidate, configured), nil - } +func (p plainPasswordType) Verify(candidate string) (bool, error) { + return subtle.ConstantTimeCompare([]byte(p.Hashed), []byte(candidate)) == 1, nil } -func verifyAgainstHash(password string, hashedPassword string) (bool, error) { - algo, err := detectHashAlgorithm(hashedPassword) +type bcryptPasswordType struct { + basePasswordType +} + +func (p bcryptPasswordType) Verify(candidate string) (bool, error) { + err := bcrypt.CompareHashAndPassword([]byte(p.Hashed), []byte(candidate)) if err != nil { return false, err + } else { + return true, nil } - if algo == hashAlgorithmTypeBCrypt { - err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) - if err != nil { - return false, err - } else { - return true, nil - } - } else if algo == hashAlgorithmTypeArgon2id { - var ver int - parts := strings.Split(hashedPassword, "$") - _, err = fmt.Sscanf(parts[2], "v=%d", &ver) - if ver != argon2.Version { - return false, fmt.Errorf("Password hash uses incompatible version of Argon2id (want %d, given %d)", argon2.Version, ver) - } - // TODO: These are technically optional. Use defaults if absent - var mem uint32 - var iter uint32 - var para uint8 - _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &iter, ¶) - if err != nil { - return false, err - } - salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) - if err != nil { - return false, err - } - key, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) - if err != nil { - return false, err - } - keylen := int32(len(key)) - candidateKey := argon2.IDKey([]byte(password), salt, iter, mem, para, uint32(keylen)) - candidateKeylen := int32(len(candidateKey)) +} - if subtle.ConstantTimeEq(keylen, candidateKeylen) == 0 { - return false, nil - } - if subtle.ConstantTimeCompare(key, candidateKey) == 1 { - return true, nil - } - return false, nil - } - panic(fmt.Sprintf("Password hash algorithm not implemented: %s", algo)) +type argon2idPasswordType struct { + basePasswordType + Version int + Memory uint32 + Time uint32 + Threads uint8 + Salt []byte + Key []byte + KeyLen int32 } -func verifyAgainstPlaintext(pwd1 string, pwd2 string) bool { - return subtle.ConstantTimeCompare([]byte(pwd1), []byte(pwd2)) == 1 +func (p argon2idPasswordType) Verify(candidate string) (bool, error) { + candidateKey := argon2.IDKey([]byte(candidate), p.Salt, p.Time, p.Memory, p.Threads, uint32(p.KeyLen)) + candidateKeylen := int32(len(candidateKey)) + + if subtle.ConstantTimeEq(p.KeyLen, candidateKeylen) == 0 { + return false, nil + } + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil } -// isSupportedPasswordHash returns true if the supplied password is actually a -// hash as defined by the PHC format that Faktory supports. Per OWASP guidance, -// only Argon2id, scrypt, bcrypt, and PBKDF2 hashes are supported. -// -// https://github.com/P-H-C/phc-string-format -// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html -func isSupportedPasswordHash(pwd string) bool { - algo, err := detectHashAlgorithm(pwd) +func Verify(candidate string, configured string) (bool, error) { + algo, err := detectHashAlgorithm(configured) if err != nil { - return false + return false, err } - return algo != hashAlgorithmTypeUnknown + return algo.Verify(candidate) } -// TODO: return a genericish struct/interface or something so we don't -// wastefully keep parsing the string over and over -func detectHashAlgorithm(pwd string) (hashAlgorithmType, error) { +func detectHashAlgorithm(pwd string) (PasswordType, error) { // TODO: do a fulsome parsing of PHC format parts := strings.Split(pwd, "$") + upt := plainPasswordType{} + upt.Hashed = pwd if parts[0] != "" || len(parts) < 2 || len(parts[1]) < 1 { - return hashAlgorithmTypeUnknown, errors.New("not a recognizable password hash format") + util.Warn("Not a recognizable password hash format, assuming plaintext") + return upt, nil } if hashID(parts[1][0]) == hashIDBCrypt2 { - return hashAlgorithmTypeBCrypt, nil + pt := bcryptPasswordType{} + pt.Hashed = pwd + return pt, nil } if hashID(parts[1]) == hashIDArgon2id { - return hashAlgorithmTypeArgon2id, nil + pt := argon2idPasswordType{} + pt.Hashed = pwd + _, err := fmt.Sscanf(parts[2], "v=%d", &pt.Version) + if pt.Version != argon2.Version { + return nil, fmt.Errorf("Password hash uses incompatible version of Argon2id (want %d, given %d)", argon2.Version, pt.Version) + } + // TODO: These are technically optional. Use defaults if absent + _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pt.Memory, &pt.Time, &pt.Threads) + if err != nil { + return nil, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return nil, err + } + pt.Salt = salt + key, err := base64.RawStdEncoding.Strict().DecodeString(parts[5]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = int32(len(pt.Key)) + return pt, nil } - return hashAlgorithmTypeUnknown, fmt.Errorf("unknown password hash algorithm id %s", parts[1]) + util.Warnf("Unknown password hash algorithm ID %s, assuming plaintext", parts[1]) + return upt, nil } From 3103bcfbf4baf7cc52177a03abffcdfd348fe4ed Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 13:15:09 -0400 Subject: [PATCH 06/11] Add scrypt support; document; add tests --- password/password.go | 87 +++++++++++++++++++++++++++++++---- password/password_test.go | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 password/password_test.go diff --git a/password/password.go b/password/password.go index 130200a..9ce742b 100644 --- a/password/password.go +++ b/password/password.go @@ -4,11 +4,13 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "math" "strings" "github.com/contribsys/faktory/util" "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" ) type hashID string @@ -16,8 +18,11 @@ type hashID string const ( hashIDBCrypt2 hashID = "2" // technically a major ver only hashIDArgon2id hashID = "argon2id" + hashIDScrypt hashID = "scrypt" ) +// PasswordType interface describes the common way to verify a password for a +// given particular hashing/algorithm strategy type PasswordType interface { Verify(candidate string) (bool, error) } @@ -58,10 +63,16 @@ type argon2idPasswordType struct { } func (p argon2idPasswordType) Verify(candidate string) (bool, error) { - candidateKey := argon2.IDKey([]byte(candidate), p.Salt, p.Time, p.Memory, p.Threads, uint32(p.KeyLen)) - candidateKeylen := int32(len(candidateKey)) + candidateKey := argon2.IDKey([]byte(candidate), + p.Salt, + p.Time, + p.Memory, + p.Threads, + uint32(p.KeyLen), + ) + candidateKeyLen := int32(len(candidateKey)) - if subtle.ConstantTimeEq(p.KeyLen, candidateKeylen) == 0 { + if subtle.ConstantTimeEq(p.KeyLen, candidateKeyLen) == 0 { return false, nil } if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { @@ -70,6 +81,46 @@ func (p argon2idPasswordType) Verify(candidate string) (bool, error) { return false, nil } +type scryptPasswordType struct { + basePasswordType + Salt []byte + LogCost int + BlockSize int + Parallelism int + Key []byte + KeyLen int +} + +func (p scryptPasswordType) Verify(candidate string) (bool, error) { + candidateKey, err := scrypt.Key( + []byte(candidate), + p.Salt, + int(math.Pow(2, float64(p.LogCost))), + p.BlockSize, + p.Parallelism, + p.KeyLen, + ) + candidateKeyLen := int32(len(candidateKey)) + + if err != nil { + return false, err + } + if subtle.ConstantTimeEq(int32(p.KeyLen), candidateKeyLen) == 0 { + return false, nil + } + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil +} + +// Verify returns true if a `candidate` password matches the `configured` one, +// which may or may not by hashed with different standardized hashing +// algorithms. If an algorithm cannot be detected, it is assumed the +// `configured` password is in plaintext. +// +// An error is returned when unable to decode the hash correctly or if the +// underlying hashing library returns an error during verification. func Verify(candidate string, configured string) (bool, error) { algo, err := detectHashAlgorithm(configured) if err != nil { @@ -81,11 +132,11 @@ func Verify(candidate string, configured string) (bool, error) { func detectHashAlgorithm(pwd string) (PasswordType, error) { // TODO: do a fulsome parsing of PHC format parts := strings.Split(pwd, "$") - upt := plainPasswordType{} - upt.Hashed = pwd + ppt := plainPasswordType{} + ppt.Hashed = pwd if parts[0] != "" || len(parts) < 2 || len(parts[1]) < 1 { util.Warn("Not a recognizable password hash format, assuming plaintext") - return upt, nil + return ppt, nil } if hashID(parts[1][0]) == hashIDBCrypt2 { pt := bcryptPasswordType{} @@ -99,7 +150,6 @@ func detectHashAlgorithm(pwd string) (PasswordType, error) { if pt.Version != argon2.Version { return nil, fmt.Errorf("Password hash uses incompatible version of Argon2id (want %d, given %d)", argon2.Version, pt.Version) } - // TODO: These are technically optional. Use defaults if absent _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pt.Memory, &pt.Time, &pt.Threads) if err != nil { return nil, err @@ -117,6 +167,27 @@ func detectHashAlgorithm(pwd string) (PasswordType, error) { pt.KeyLen = int32(len(pt.Key)) return pt, nil } + if hashID(parts[1]) == hashIDScrypt { + pt := scryptPasswordType{} + pt.Hashed = pwd + _, err := fmt.Sscanf(parts[2], "ln=%d,r=%d,p=%d", &pt.LogCost, &pt.BlockSize, &pt.Parallelism) + if err != nil { + return nil, err + } + salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[3]) + if err != nil { + return nil, err + } + pt.Salt = salt + + key, err := base64.RawStdEncoding.Strict().DecodeString(parts[4]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = len(pt.Key) + return pt, nil + } util.Warnf("Unknown password hash algorithm ID %s, assuming plaintext", parts[1]) - return upt, nil + return ppt, nil } diff --git a/password/password_test.go b/password/password_test.go new file mode 100644 index 0000000..6201de6 --- /dev/null +++ b/password/password_test.go @@ -0,0 +1,96 @@ +package password + +import ( + "testing" +) + +// PHC-formatted hashes produced using Python's `passlib` library: +// +// from passlib import hash +// hash.bcrypt.hash("a") # => "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW" +// hash.scrypt.hash("a") # => "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw" +func TestPasswordVerify(t *testing.T) { + tests := + []struct { + name string + candidate string + configured string + verified bool + expectErr bool + errMessage string + }{ + { + name: "bcrypt correct", + candidate: "a", + configured: "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW", + verified: true, + }, + { + name: "bcrypt incorrect", + candidate: "wrong", + configured: "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW", + verified: false, + expectErr: true, + errMessage: "crypto/bcrypt: hashedPassword is not the hash of the given password", + }, + { + name: "argon2id correct", + candidate: "a", + configured: "$argon2id$v=19$m=65536,t=3,p=4$VSrlfE9pLcW4977XGiOklA$GIIN05JoObiRMLBpP+iPBHeemyovXJvM4Zi2JU82XFg", + verified: true, + }, + { + name: "argon2id incorrect", + candidate: "wrong", + configured: "$argon2id$v=19$m=65536,t=3,p=4$VSrlfE9pLcW4977XGiOklA$GIIN05JoObiRMLBpP+iPBHeemyovXJvM4Zi2JU82XFg", + verified: false, + }, + { + name: "scrypt correct", + candidate: "a", + configured: "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw", + verified: true, + }, + { + name: "scrypt incorrect", + candidate: "wrong", + configured: "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw", + verified: false, + }, + { + name: "plaintext correct", + candidate: "a", + configured: "a", + verified: true, + }, + { + name: "plaintext incorrect", + candidate: "wrong", + configured: "a", + verified: false, + }, + { + name: "PHC-ish looking plaintext", + candidate: "$bcrypt$tography", + configured: "$bcrypt$tography", + verified: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := Verify(tt.candidate, tt.configured) + if v != tt.verified { + t.Errorf("Verify(\"%s\", \"%s\") = %v; want %v", tt.candidate, tt.configured, v, tt.verified) + } + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + if err.Error() != tt.errMessage { + t.Errorf("expected error %q, got %q", tt.errMessage, err.Error()) + } + } + }) + } +} From 1600cd9ec02164ead50e057cbdd6469c889ab3da Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 14:05:43 -0400 Subject: [PATCH 07/11] Add PBKDF2 support --- password/password.go | 79 +++++++++++++++++++++++++++++++++++++++ password/password_test.go | 47 +++++++++++++++++++++-- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/password/password.go b/password/password.go index 9ce742b..010c656 100644 --- a/password/password.go +++ b/password/password.go @@ -1,9 +1,15 @@ package password import ( + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" "crypto/subtle" "encoding/base64" + "errors" "fmt" + "hash" "math" "strings" @@ -19,6 +25,7 @@ const ( hashIDBCrypt2 hashID = "2" // technically a major ver only hashIDArgon2id hashID = "argon2id" hashIDScrypt hashID = "scrypt" + hashIDPBKDF2 hashID = "pbkdf2" // technically the first part of the identifier ) // PasswordType interface describes the common way to verify a password for a @@ -114,6 +121,37 @@ func (p scryptPasswordType) Verify(candidate string) (bool, error) { return false, nil } +type pbkdf2PasswordType struct { + basePasswordType + DigestFunc func() hash.Hash + Salt []byte + Rounds int + Key []byte + KeyLen int +} + +func (p pbkdf2PasswordType) Verify(candidate string) (bool, error) { + candidateKey, err := pbkdf2.Key( + p.DigestFunc, + candidate, + p.Salt, + p.Rounds, + p.KeyLen, + ) + candidateKeyLen := int32(len(candidateKey)) + + if err != nil { + return false, err + } + if subtle.ConstantTimeEq(int32(p.KeyLen), candidateKeyLen) == 0 { + return false, nil + } + if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { + return true, nil + } + return false, nil +} + // Verify returns true if a `candidate` password matches the `configured` one, // which may or may not by hashed with different standardized hashing // algorithms. If an algorithm cannot be detected, it is assumed the @@ -188,6 +226,47 @@ func detectHashAlgorithm(pwd string) (PasswordType, error) { pt.KeyLen = len(pt.Key) return pt, nil } + if strings.HasPrefix(parts[1], string(hashIDPBKDF2)) { + digestIdent, found := strings.CutPrefix(parts[1], string(hashIDPBKDF2)) + if !found { + return nil, errors.New("Could not find digest algo from PBKDF2 hash") + } + pt := pbkdf2PasswordType{} + pt.Hashed = pwd + switch digestIdent { + case "", "-sha1": + pt.DigestFunc = sha1.New + case "-sha256": + pt.DigestFunc = sha256.New + case "-sha512": + pt.DigestFunc = sha512.New + default: + return nil, fmt.Errorf("Unsupported digest ID %s for PBKDF2 hash", digestIdent) + } + _, err := fmt.Sscanf(parts[2], "%d", &pt.Rounds) + if err != nil { + return nil, err + } + salt, err := ab64Decode(parts[3]) + if err != nil { + return nil, err + } + pt.Salt = salt + key, err := ab64Decode(parts[4]) + if err != nil { + return nil, err + } + pt.Key = key + pt.KeyLen = len(pt.Key) + return pt, nil + } util.Warnf("Unknown password hash algorithm ID %s, assuming plaintext", parts[1]) return ppt, nil } + +// ab64Decode from strict and raw base64 format which omits padding & +// whitespace. Supports both custom ./ and normal +/ altchars. +func ab64Decode(s string) ([]byte, error) { + b64s := strings.ReplaceAll(s, ".", "+") + return base64.RawStdEncoding.Strict().DecodeString(b64s) +} diff --git a/password/password_test.go b/password/password_test.go index 6201de6..1565e60 100644 --- a/password/password_test.go +++ b/password/password_test.go @@ -57,6 +57,42 @@ func TestPasswordVerify(t *testing.T) { configured: "$scrypt$ln=16,r=8,p=1$AaAU4tybc06JUcoZo1RK6Q$xsBBX09sq48k30ngJqoUepwYM3T/HJn9K7eYzgIyNbw", verified: false, }, + { + name: "pbkdf2-hmac-sha1 correct", + candidate: "a", + configured: "$pbkdf2$131000$j3FurVUqxbiXUuqdc865Fw$khjQ9RJHk0901AZmqtUnudHQmDg", + verified: true, + }, + { + name: "pbkdf2-hmac-sha1 incorrect", + candidate: "wrong", + configured: "$pbkdf2$131000$j3FurVUqxbiXUuqdc865Fw$khjQ9RJHk0901AZmqtUnudHQmDg", + verified: false, + }, + { + name: "pbkdf2-hmac-sha256 correct", + candidate: "a", + configured: "$pbkdf2-sha256$29000$EqJUqlXqfa/13hsDYGyNsQ$mySn2pP1vbxyIA2/ExJqoHDc0ywnwf4SSJPavT6n3oA", + verified: true, + }, + { + name: "pbkdf2-hmac-sha256 incorrect", + candidate: "wrong", + configured: "$pbkdf2-sha256$29000$EqJUqlXqfa/13hsDYGyNsQ$mySn2pP1vbxyIA2/ExJqoHDc0ywnwf4SSJPavT6n3oA", + verified: false, + }, + { + name: "pbkdf2-hmac-sha512 correct", + candidate: "a", + configured: "$pbkdf2-sha512$25000$QmhtjZEyJuR8r3UOoVRKaQ$EiqzPjoOZkEt3SKVZv9g31/kaj8WXIaey5pNWWVczZrJXXeuA9CU.vlJ3AgYS6CqojXtpgC1P0kJwkevKDMqMw", + verified: true, + }, + { + name: "pbkdf2-hmac-sha512 incorrect", + candidate: "wrong", + configured: "$pbkdf2-sha512$25000$QmhtjZEyJuR8r3UOoVRKaQ$EiqzPjoOZkEt3SKVZv9g31/kaj8WXIaey5pNWWVczZrJXXeuA9CU.vlJ3AgYS6CqojXtpgC1P0kJwkevKDMqMw", + verified: false, + }, { name: "plaintext correct", candidate: "a", @@ -80,9 +116,7 @@ func TestPasswordVerify(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Verify(tt.candidate, tt.configured) - if v != tt.verified { - t.Errorf("Verify(\"%s\", \"%s\") = %v; want %v", tt.candidate, tt.configured, v, tt.verified) - } + if tt.expectErr { if err == nil { t.Fatalf("expected error but got nil") @@ -90,6 +124,13 @@ func TestPasswordVerify(t *testing.T) { if err.Error() != tt.errMessage { t.Errorf("expected error %q, got %q", tt.errMessage, err.Error()) } + } else { + if err != nil { + t.Errorf("did not expect error, got %q", err.Error()) + } + if v != tt.verified { + t.Errorf("Verify(\"%s\", \"%s\") = %v; want %v", tt.candidate, tt.configured, v, tt.verified) + } } }) } From db81482bf30bf1971ae06070658821f0122fcc39 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 14:06:48 -0400 Subject: [PATCH 08/11] remove explicit keylen check --- password/password.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/password/password.go b/password/password.go index 010c656..968e6ee 100644 --- a/password/password.go +++ b/password/password.go @@ -77,11 +77,7 @@ func (p argon2idPasswordType) Verify(candidate string) (bool, error) { p.Threads, uint32(p.KeyLen), ) - candidateKeyLen := int32(len(candidateKey)) - if subtle.ConstantTimeEq(p.KeyLen, candidateKeyLen) == 0 { - return false, nil - } if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { return true, nil } @@ -107,14 +103,10 @@ func (p scryptPasswordType) Verify(candidate string) (bool, error) { p.Parallelism, p.KeyLen, ) - candidateKeyLen := int32(len(candidateKey)) if err != nil { return false, err } - if subtle.ConstantTimeEq(int32(p.KeyLen), candidateKeyLen) == 0 { - return false, nil - } if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { return true, nil } @@ -138,14 +130,10 @@ func (p pbkdf2PasswordType) Verify(candidate string) (bool, error) { p.Rounds, p.KeyLen, ) - candidateKeyLen := int32(len(candidateKey)) if err != nil { return false, err } - if subtle.ConstantTimeEq(int32(p.KeyLen), candidateKeyLen) == 0 { - return false, nil - } if subtle.ConstantTimeCompare(p.Key, candidateKey) == 1 { return true, nil } From 553742500c667fad9200ad91ea1ad883c863def1 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 14:17:51 -0400 Subject: [PATCH 09/11] Additional test coverage --- cli/security_test.go | 27 +++++++++++++++++++++++++++ webui/web.go | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cli/security_test.go b/cli/security_test.go index d84c4f6..1545c2b 100644 --- a/cli/security_test.go +++ b/cli/security_test.go @@ -14,6 +14,13 @@ func pwdCfg(value string) map[string]any { }, } } +func webPwdCfg(value string) map[string]any { + return map[string]any{ + "web": map[string]any{ + "password": value, + }, + } +} func TestPasswords(t *testing.T) { emptyCfg := map[string]any{} @@ -29,6 +36,15 @@ func TestPasswords(t *testing.T) { assert.Equal(t, "********", cfg["faktory"].(map[string]any)["password"]) }) + t.Run("DevWithWebPassword", func(t *testing.T) { + cfg := webPwdCfg(pwd) + pwd, err := fetchPassword(cfg, "development", passwordTypeWebUI) + assert.NoError(t, err) + assert.Equal(t, 16, len(pwd)) + assert.Equal(t, "cce29d6565ab7376", pwd) + assert.Equal(t, "********", cfg["web"].(map[string]any)["password"]) + }) + t.Run("DevWithoutPassword", func(t *testing.T) { pwd, err := fetchPassword(emptyCfg, "development", passwordTypeServer) assert.NoError(t, err) @@ -70,6 +86,16 @@ func TestPasswords(t *testing.T) { os.Unsetenv("FAKTORY_PASSWORD") + t.Run("ProductionEnvPassword", func(t *testing.T) { + os.Setenv("FAKTORY_WEBUI_PASSWORD", "webuipass") + + pwd, err := fetchPassword(emptyCfg, "production", passwordTypeWebUI) + assert.NoError(t, err) + assert.Equal(t, "webuipass", pwd) + }) + + os.Unsetenv("FAKTORY_WEBUI_PASSWORD") + t.Run("ProductionSkipPassword", func(t *testing.T) { os.Setenv("FAKTORY_SKIP_PASSWORD", "yes") @@ -79,4 +105,5 @@ func TestPasswords(t *testing.T) { }) os.Unsetenv("FAKTORY_SKIP_PASSWORD") + } diff --git a/webui/web.go b/webui/web.go index 5365653..bcaf16a 100644 --- a/webui/web.go +++ b/webui/web.go @@ -337,7 +337,7 @@ func basicAuth(srvPwd string, pass http.HandlerFunc) http.HandlerFunc { verified, err := password.Verify(pwd, srvPwd) if !verified { if err != nil { - util.Error("Failed password verification", err) + util.Error("Error during password verification", err) } w.Header().Set("WWW-Authenticate", `Basic realm="Faktory"`) http.Error(w, "Authorization failed", http.StatusUnauthorized) From 2daf3d2f3abaf9834c1812ba250503f66800439b Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Tue, 10 Jun 2025 14:24:19 -0400 Subject: [PATCH 10/11] update example config.toml with password elements --- example/config.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/example/config.toml b/example/config.toml index f146a11..d6fec8d 100644 --- a/example/config.toml +++ b/example/config.toml @@ -1,3 +1,15 @@ +[faktory] +# Required for production. Can use `FAKTORY_PASSWORD` env var instead +password = "a-password-for-clients-to-connect-with" + +[web] +# Optional. Specify to make the Web UI's HTTP Basic auth password different +# from the Faktory server password clients connect with. Supports PHC-formatted +# argon2id, bcrypt, scrypt, and pbkdf2 hashes and not just plaintext. +# e.g. "$2b$12$xbQjW9Gtc35jLaAnGp7iV.PMoDuu0SdfWUrv30B6NT1vTrGc4LTPW" +# Can use `FAKTORY_WEBUI_PASSWORD` env var instead. +password = "an-optionally-different-http-basic-webui-password-from-faktory-server" + [queues] # disable backpressure by default backpressure = 0 From f74d7c6f5b0c4e5981e58c97d707b8c340f6b8c0 Mon Sep 17 00:00:00 2001 From: Justin Bull Date: Wed, 11 Jun 2025 07:44:56 -0400 Subject: [PATCH 11/11] go mod tidy --- go.mod | 4 ++-- go.sum | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 8e0ef53..03c65bd 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.24 require ( github.com/BurntSushi/toml v1.5.0 - github.com/contribsys/faktory_worker_go v1.7.0 github.com/justinas/nosurf v1.2.0 github.com/redis/go-redis/v9 v9.7.3 + golang.org/x/crypto v0.39.0 ) require ( @@ -14,7 +14,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 7a6f4b0..077e3ee 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,10 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/contribsys/faktory_worker_go v1.7.0 h1:YSZRj/nSjn+2ms9ooHIOHAahHJehXutgD46b+0lHDP4= -github.com/contribsys/faktory_worker_go v1.7.0/go.mod h1:JRw4PvanwLgX5IIQazw6W5zg/Rg7/Fg6YrkdH4gJJPc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= -github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -24,6 +20,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=