diff --git a/internal/auth/account.go b/internal/auth/account.go
index dcb3ef5..bd99259 100644
--- a/internal/auth/account.go
+++ b/internal/auth/account.go
@@ -3,6 +3,7 @@ package auth
import (
"encoding/hex"
"errors"
+ "fmt"
"net/http"
"net/mail"
"strconv"
@@ -87,12 +88,15 @@ func (s *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, uid int
"Platforms": store.UserLinkPlatforms(),
"PlatformsJS": web.LinkPlatformsJS(store.UserLinkPlatforms()),
"HasPassword": u.PasswordHash != nil,
- "Passkeys": views,
- "NextRename": nextRename, // nil when a rename is allowed now
- "Error": r.URL.Query().Get("error"),
- "OK": r.URL.Query().Get("ok"),
- "PKMsg": r.URL.Query().Get("pk"),
- "PKErr": r.URL.Query().Get("pkerr"),
+ // Stated in the change-password form and used as minlength, so the client
+ // hint can't drift from the server's floor.
+ "MinPasswordLen": MinPasswordLen,
+ "Passkeys": views,
+ "NextRename": nextRename, // nil when a rename is allowed now
+ "Error": r.URL.Query().Get("error"),
+ "OK": r.URL.Query().Get("ok"),
+ "PKMsg": r.URL.Query().Get("pk"),
+ "PKErr": r.URL.Query().Get("pkerr"),
}
for k, v := range overrides {
data[k] = v
@@ -421,7 +425,8 @@ func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
newPassword := r.FormValue("new_password")
if !ValidPassword(newPassword) {
- accountRedirect(w, r, "error", "New password must be at least 8 characters.")
+ accountRedirect(w, r, "error", fmt.Sprintf(
+ "New password must be at least %d characters.", MinPasswordLen))
return
}
// When a password already exists, require the current one to change it.
diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go
index e5b43dd..53110c1 100644
--- a/internal/auth/handlers.go
+++ b/internal/auth/handlers.go
@@ -411,7 +411,17 @@ func NormalizePasskeyName(s string) string {
// MinPasswordLen is a basic strength floor. There is deliberately no maximum:
// passwords are pre-hashed before bcrypt (see hashPassword), so bcrypt's 72-byte
// input limit never reaches the user.
-const MinPasswordLen = 8
+//
+// It is enforced only where a password is *set* (signup and the account page's
+// change-password form), never on the verify path — so raising it doesn't lock out
+// accounts whose existing password predates the increase. They keep signing in, and
+// only have to meet the current floor if they choose a new password.
+//
+// 12 rather than 8 because length is the only strength signal here (no complexity
+// rules, no breach-list check) and there is no account recovery to fall back on if a
+// weak password is guessed — see the audit's B1. Passkeys remain the preferred path
+// and are offered first on both the sign-in and sign-up forms.
+const MinPasswordLen = 12
// ValidPassword reports whether p meets the minimum length.
func ValidPassword(p string) bool {
diff --git a/internal/auth/password_handlers.go b/internal/auth/password_handlers.go
index 51c4f37..1876e4b 100644
--- a/internal/auth/password_handlers.go
+++ b/internal/auth/password_handlers.go
@@ -2,6 +2,7 @@ package auth
import (
"errors"
+ "fmt"
"net/http"
"net/url"
@@ -14,7 +15,9 @@ func (s *Service) SignupPassword(w http.ResponseWriter, r *http.Request) {
displayName := NormalizeDisplayName(r.FormValue("display_name"))
password := r.FormValue("password")
if !ValidUsername(username) || !ValidPassword(password) {
- redirectErr(w, r, "/signup", "Choose a username (3–32 chars: letters, digits, _ . -) and a password of at least 8 characters.")
+ redirectErr(w, r, "/signup", fmt.Sprintf(
+ "Choose a username (3–32 chars: letters, digits, _ . -) and a password of at least %d characters.",
+ MinPasswordLen))
return
}
diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go
index 2238ff4..308f022 100644
--- a/internal/auth/service_test.go
+++ b/internal/auth/service_test.go
@@ -7,21 +7,39 @@ import (
"golang.org/x/crypto/bcrypt"
)
-// TestValidPassword pins the 8-char floor and confirms there is no upper bound:
-// pre-hashing removes bcrypt's 72-byte input limit, so long passwords are valid.
+// TestValidPassword pins the floor at MinPasswordLen and confirms there is no upper
+// bound: pre-hashing removes bcrypt's 72-byte input limit, so long passwords are
+// valid.
+//
+// The boundary cases are expressed relative to the constant rather than hardcoded, so
+// changing the floor doesn't require editing the expectations — only the explicit
+// "old floor" case below, which is deliberate.
func TestValidPassword(t *testing.T) {
t.Parallel()
cases := []struct {
n int
want bool
}{
- {7, false}, {8, true}, {72, true}, {73, true}, {5000, true},
+ {MinPasswordLen - 1, false},
+ {MinPasswordLen, true},
+ {72, true}, // bcrypt's raw input limit — pre-hashing means it isn't ours
+ {73, true}, // past it
+ {5000, true}, // no maximum at all
}
for _, c := range cases {
if got := ValidPassword(strings.Repeat("a", c.n)); got != c.want {
t.Errorf("ValidPassword(len=%d) = %v, want %v", c.n, got, c.want)
}
}
+ // The floor was raised from 8 to 12 (audit S7); 8 must now be rejected when a
+ // password is *set*. Existing shorter passwords keep working because the floor is
+ // never applied on the verify path — see TestFloorNotAppliedToExistingPasswords.
+ if ValidPassword("12345678") {
+ t.Error("an 8-character password is still accepted; the floor didn't move")
+ }
+ if MinPasswordLen < 12 {
+ t.Errorf("MinPasswordLen = %d, want at least 12", MinPasswordLen)
+ }
}
// TestPasswordHashRoundTrip: a password far past bcrypt's 72-byte input limit
diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html
index 677198e..6d3c9a7 100644
--- a/internal/auth/templates/account.html
+++ b/internal/auth/templates/account.html
@@ -236,9 +236,9 @@
{{end}}