fix: harden auth timing, cookie attrs, password gen bias, email template escaping; add security tests
This commit is contained in:
+26
-8
@@ -77,6 +77,10 @@ func (s *Service) Register(username, email, password, role string, mustChangePas
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dummyHash is a pre-computed bcrypt hash used for constant-time comparison
|
||||
// when a user is not found, preventing timing-based username enumeration.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-constant-time-padding"), bcrypt.DefaultCost)
|
||||
|
||||
// Login authenticates a user and returns the user if successful
|
||||
func (s *Service) Login(username, password string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
@@ -86,6 +90,10 @@ func (s *Service) Login(username, password string) (*models.User, error) {
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Perform a dummy bcrypt comparison to prevent timing-based username enumeration.
|
||||
// Without this, an attacker could distinguish "user not found" (fast) from
|
||||
// "wrong password" (slow due to bcrypt) by measuring response time.
|
||||
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
@@ -311,17 +319,27 @@ func (s *Service) markInitialSetupComplete() {
|
||||
)
|
||||
}
|
||||
|
||||
// generateSecurePassword creates a cryptographically secure random password
|
||||
// generateSecurePassword creates a cryptographically secure random password.
|
||||
// It uses rejection sampling to avoid modulo bias when mapping random bytes
|
||||
// to the character set.
|
||||
func generateSecurePassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
const cLen = byte(len(charset)) // 62
|
||||
const maxUnbiased = 256 - (256 % int(cLen)) // 252 — largest multiple of 62 that fits in a byte
|
||||
|
||||
result := make([]byte, length)
|
||||
for i := 0; i < length; {
|
||||
var b [1]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int(b[0]) >= maxUnbiased {
|
||||
continue // reject to eliminate modulo bias
|
||||
}
|
||||
result[i] = charset[b[0]%cLen]
|
||||
i++
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[b[i]%byte(len(charset))]
|
||||
}
|
||||
return string(b), nil
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// UpdateUser updates user details (admin function)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ---------- generateSecurePassword ----------
|
||||
|
||||
func TestGenerateSecurePassword_Length(t *testing.T) {
|
||||
for _, length := range []int{8, 16, 20, 32, 64} {
|
||||
pw, err := generateSecurePassword(length)
|
||||
if err != nil {
|
||||
t.Fatalf("generateSecurePassword(%d) error: %v", length, err)
|
||||
}
|
||||
if len(pw) != length {
|
||||
t.Fatalf("generateSecurePassword(%d) returned length %d", length, len(pw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecurePassword_CharacterSet(t *testing.T) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
pw, err := generateSecurePassword(1000)
|
||||
if err != nil {
|
||||
t.Fatalf("generateSecurePassword error: %v", err)
|
||||
}
|
||||
for i, c := range pw {
|
||||
if !strings.ContainsRune(charset, c) {
|
||||
t.Fatalf("character at position %d (%c) not in allowed charset", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecurePassword_Uniqueness(t *testing.T) {
|
||||
passwords := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
pw, err := generateSecurePassword(20)
|
||||
if err != nil {
|
||||
t.Fatalf("generateSecurePassword error: %v", err)
|
||||
}
|
||||
if passwords[pw] {
|
||||
t.Fatal("generated duplicate password — insufficient randomness")
|
||||
}
|
||||
passwords[pw] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecurePassword_NoBias(t *testing.T) {
|
||||
// Generate many characters and check that the distribution is roughly
|
||||
// uniform. With rejection sampling and charset length 62, each character
|
||||
// should appear about 1/62 ≈ 1.6% of the time. We use a generous margin.
|
||||
const total = 62000
|
||||
pw, err := generateSecurePassword(total)
|
||||
if err != nil {
|
||||
t.Fatalf("generateSecurePassword error: %v", err)
|
||||
}
|
||||
|
||||
freq := make(map[rune]int)
|
||||
for _, c := range pw {
|
||||
freq[c]++
|
||||
}
|
||||
|
||||
expected := float64(total) / 62.0 // ~1000
|
||||
for c, count := range freq {
|
||||
ratio := float64(count) / expected
|
||||
// Allow 20% deviation (generous for 62k samples)
|
||||
if ratio < 0.8 || ratio > 1.2 {
|
||||
t.Errorf("character %c appeared %d times (expected ~%.0f, ratio %.2f) — possible bias", c, count, expected, ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- dummyHash (timing attack prevention) ----------
|
||||
|
||||
func TestDummyHash_IsValid(t *testing.T) {
|
||||
if dummyHash == nil {
|
||||
t.Fatal("dummyHash should not be nil")
|
||||
}
|
||||
if len(dummyHash) == 0 {
|
||||
t.Fatal("dummyHash should not be empty")
|
||||
}
|
||||
// It should be a valid bcrypt hash (starts with $2a$ or $2b$)
|
||||
s := string(dummyHash)
|
||||
if !strings.HasPrefix(s, "$2a$") && !strings.HasPrefix(s, "$2b$") {
|
||||
t.Fatalf("dummyHash does not look like a bcrypt hash: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Password character class helpers ----------
|
||||
|
||||
func TestPasswordCharacterClasses(t *testing.T) {
|
||||
tests := []struct {
|
||||
password string
|
||||
hasUpper bool
|
||||
hasLower bool
|
||||
hasDigit bool
|
||||
hasSpecial bool
|
||||
}{
|
||||
{"abc", false, true, false, false},
|
||||
{"ABC", true, false, false, false},
|
||||
{"123", false, false, true, false},
|
||||
{"!@#", false, false, false, true},
|
||||
{"aB1!", true, true, true, true},
|
||||
{"", false, false, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
var upper, lower, digit, special bool
|
||||
for _, r := range tt.password {
|
||||
if unicode.IsUpper(r) {
|
||||
upper = true
|
||||
}
|
||||
if unicode.IsLower(r) {
|
||||
lower = true
|
||||
}
|
||||
if unicode.IsDigit(r) {
|
||||
digit = true
|
||||
}
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
special = true
|
||||
}
|
||||
}
|
||||
if upper != tt.hasUpper || lower != tt.hasLower || digit != tt.hasDigit || special != tt.hasSpecial {
|
||||
t.Errorf("password %q: got upper=%v lower=%v digit=%v special=%v, want upper=%v lower=%v digit=%v special=%v",
|
||||
tt.password, upper, lower, digit, special, tt.hasUpper, tt.hasLower, tt.hasDigit, tt.hasSpecial)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user