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/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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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=
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user