From 0e2c40b5ca8b2dd0067d4ae31895d09849d3635b Mon Sep 17 00:00:00 2001 From: ae Date: Mon, 14 Apr 2025 14:14:52 +0300 Subject: [PATCH] feat: switch to built-in entropy calc --- server/go.mod | 1 - server/go.sum | 2 - server/internal/service/util.go | 87 +++++++++++++++++----------- server/internal/service/util_test.go | 11 ++-- 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/server/go.mod b/server/go.mod index bd5c341..d73c586 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,7 +13,6 @@ require ( github.com/jackc/pgx/v5 v5.7.4 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.10.0 - github.com/wagslane/go-password-validator v0.3.0 golang.org/x/crypto v0.36.0 ) diff --git a/server/go.sum b/server/go.sum index a491459..49f75c3 100644 --- a/server/go.sum +++ b/server/go.sum @@ -95,8 +95,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= -github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= diff --git a/server/internal/service/util.go b/server/internal/service/util.go index 99a152e..25da334 100644 --- a/server/internal/service/util.go +++ b/server/internal/service/util.go @@ -7,13 +7,13 @@ import ( "errors" "fmt" "io" + "math" "net/http" "regexp" "strconv" "strings" + "unicode" "unicode/utf8" - - passwordvalidator "github.com/wagslane/go-password-validator" ) const ( @@ -41,18 +41,6 @@ func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } -/* - Example client-side check: - - ``` - function estimateEntropy(password: string): number { - const pool: number = getCharsetSize(password); // Character diversity (R) - const entropy: number = password.length * Math.log2(pool); // E = L * log_2(R) - return entropy; // Value (E) that can be compared against a hardcoded threshold (e.g. 60) - } - ``` -*/ - // Validate the given password using a hybrid approach: length (max. set due to bcrypt's input // upper limit of 72 bytes), entropy, and HIBP API. func validatePassword(password string) error { @@ -63,19 +51,66 @@ func validatePassword(password string) error { return fmt.Errorf("password cannot be longer than %d characters", maxPasswordLength) } - // Formatted error message will contain tips to increase the password strength (safe to show) - err := passwordvalidator.Validate(password, minPasswordEntropy) - if err != nil { - return err + // Simple entropy approximation + if calcPasswordEntropy(password) < minPasswordEntropy { + return fmt.Errorf("password is too weak, try adding more uppercase letters, digits, and symbols") } if compromised, _ := isPasswordCompromised(password); compromised { - return errors.New("password is compromised") + return errors.New("password was found in database leaks") } return nil } +// Approximate given password's entropy. Notably the way the entropy is calculated is really +// conservative and punishes relatively harshly if the password contains a lot of repetition +// (small set of unique characters). +func calcPasswordEntropy(password string) float64 { + hasLower, hasUpper, hasDigit, hasSymbol := false, false, false, false + uniqueChars := make(map[rune]bool) + + for _, c := range password { + uniqueChars[c] = true + + switch { + case unicode.IsLower(c): + hasLower = true + case unicode.IsUpper(c): + hasUpper = true + case unicode.IsDigit(c): + hasDigit = true + default: + if !unicode.IsLetter(c) && !unicode.IsDigit(c) { + hasSymbol = true // Broader symbol collection than in the frontend + } + } + } + + poolSize := 0 + if hasLower { + poolSize += 26 + } + if hasUpper { + poolSize += 26 + } + if hasDigit { + poolSize += 10 + } + if hasSymbol { + poolSize += 40 + } + + if poolSize == 0 { + return 0 + } + + basicEntropy := float64(len(password) * int(math.Log2(float64(poolSize)))) + diversityAdjustedEntropy := math.Log2(float64(poolSize)) + float64(len(password)-1)*math.Log2(float64(len(uniqueChars))) + + return math.Min(basicEntropy, diversityAdjustedEntropy) +} + // Send the first five bytes of the password's SHA-1 hash to HIBP API, then check if the rest of // the hash is present in the APi's response data (k-Anonymity model). func isPasswordCompromised(password string) (bool, error) { @@ -102,20 +137,6 @@ func normalizeUsername(username string) string { return strings.ToLower(strings.TrimSpace(username)) } -/* - Example client-side check (without input normalization): - - ``` - function validateUsername(username: string): string { - const min: number = 3, max: number = 20; - if (username.length < min) return "Too short"; - if (username.length > max) return "Too long"; - if (!/^[a-zA-Z0-9_]+$/.test(username)) return "Invalid characters"; - return "Valid"; - } - ``` -*/ - // Validate the given username by making sure it only contains alphanumeric characters or // underscores and adheres the hardcoded minimum and maximum length rules. func validateUsername(username string) error { diff --git a/server/internal/service/util_test.go b/server/internal/service/util_test.go index 99b4371..64c2220 100644 --- a/server/internal/service/util_test.go +++ b/server/internal/service/util_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - passwordvalidator "github.com/wagslane/go-password-validator" ) type MockHTTPClient struct { @@ -42,7 +41,7 @@ func TestValidatePassword(t *testing.T) { { name: "low entropy", password: strings.Repeat("a", minPasswordLength), - wantErr: "insecure password", // Error produced by wagslane/go-password-validator + wantErr: "password is too weak", }, { name: "valid password", @@ -91,21 +90,21 @@ func TestPasswordEntropyCalculation(t *testing.T) { }{ { "password", - 37.6, + 24, }, { "SecurePassw0rd!123", - 103.12, + 73, }, { "aaaaaaaaaaaaaaaa", - 9.5, + 5, }, } for _, tc := range tests { t.Run(tc.password, func(t *testing.T) { - entropy := passwordvalidator.GetEntropy(tc.password) + entropy := calcPasswordEntropy(tc.password) assert.InDelta(t, tc.entropy, entropy, 1.0) }) }