feat: switch to built-in entropy calc

This commit is contained in:
ae 2025-04-14 14:14:52 +03:00
parent 6ee0d269d8
commit 0e2c40b5ca
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
4 changed files with 59 additions and 42 deletions

View File

@ -13,7 +13,6 @@ require (
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/wagslane/go-password-validator v0.3.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
) )

View File

@ -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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 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= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=

View File

@ -7,13 +7,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
passwordvalidator "github.com/wagslane/go-password-validator"
) )
const ( const (
@ -41,18 +41,6 @@ func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message}) 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 // 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. // upper limit of 72 bytes), entropy, and HIBP API.
func validatePassword(password string) error { 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) 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) // Simple entropy approximation
err := passwordvalidator.Validate(password, minPasswordEntropy) if calcPasswordEntropy(password) < minPasswordEntropy {
if err != nil { return fmt.Errorf("password is too weak, try adding more uppercase letters, digits, and symbols")
return err
} }
if compromised, _ := isPasswordCompromised(password); compromised { if compromised, _ := isPasswordCompromised(password); compromised {
return errors.New("password is compromised") return errors.New("password was found in database leaks")
} }
return nil 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 // 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). // the hash is present in the APi's response data (k-Anonymity model).
func isPasswordCompromised(password string) (bool, error) { func isPasswordCompromised(password string) (bool, error) {
@ -102,20 +137,6 @@ func normalizeUsername(username string) string {
return strings.ToLower(strings.TrimSpace(username)) 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 // Validate the given username by making sure it only contains alphanumeric characters or
// underscores and adheres the hardcoded minimum and maximum length rules. // underscores and adheres the hardcoded minimum and maximum length rules.
func validateUsername(username string) error { func validateUsername(username string) error {

View File

@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
passwordvalidator "github.com/wagslane/go-password-validator"
) )
type MockHTTPClient struct { type MockHTTPClient struct {
@ -42,7 +41,7 @@ func TestValidatePassword(t *testing.T) {
{ {
name: "low entropy", name: "low entropy",
password: strings.Repeat("a", minPasswordLength), password: strings.Repeat("a", minPasswordLength),
wantErr: "insecure password", // Error produced by wagslane/go-password-validator wantErr: "password is too weak",
}, },
{ {
name: "valid password", name: "valid password",
@ -91,21 +90,21 @@ func TestPasswordEntropyCalculation(t *testing.T) {
}{ }{
{ {
"password", "password",
37.6, 24,
}, },
{ {
"SecurePassw0rd!123", "SecurePassw0rd!123",
103.12, 73,
}, },
{ {
"aaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaa",
9.5, 5,
}, },
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.password, func(t *testing.T) { 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) assert.InDelta(t, tc.entropy, entropy, 1.0)
}) })
} }