fix: harden auth timing, cookie attrs, password gen bias, email template escaping; add security tests

This commit is contained in:
2026-04-08 20:45:16 +02:00
parent fe31ef5a3c
commit ca402eb88e
7 changed files with 549 additions and 25 deletions
+26 -8
View File
@@ -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)
+134
View File
@@ -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)
}
}
}