feat: switch to built-in entropy calc
This commit is contained in:
parent
6ee0d269d8
commit
0e2c40b5ca
@ -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
|
||||
)
|
||||
|
||||
|
@ -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=
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user