Files
pansy/internal/service/password_test.go
T
steveandClaude Opus 4.8 02c928ac6d
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00

55 lines
1.5 KiB
Go

package service
import (
"strings"
"testing"
)
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := hashPassword("correct-horse-battery-staple")
if err != nil {
t.Fatalf("hashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
t.Errorf("hash %q lacks argon2id PHC prefix", hash)
}
ok, err := verifyPassword(hash, "correct-horse-battery-staple")
if err != nil || !ok {
t.Errorf("verify correct = (%v, %v), want (true, nil)", ok, err)
}
ok, err = verifyPassword(hash, "wrong-password")
if err != nil || ok {
t.Errorf("verify wrong = (%v, %v), want (false, nil)", ok, err)
}
}
func TestHashPasswordIsSalted(t *testing.T) {
// Two hashes of the same password must differ (random salt), yet both verify.
h1, _ := hashPassword("same-password")
h2, _ := hashPassword("same-password")
if h1 == h2 {
t.Error("two hashes of the same password are identical; salt not random")
}
for _, h := range []string{h1, h2} {
if ok, err := verifyPassword(h, "same-password"); err != nil || !ok {
t.Errorf("verify(%q) = (%v, %v), want (true, nil)", h, ok, err)
}
}
}
func TestVerifyPasswordRejectsMalformedHash(t *testing.T) {
for _, bad := range []string{
"",
"not-a-hash",
"$argon2id$v=19$m=65536,t=1,p=4$onlyfourparts",
"$argon2i$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong variant
"$argon2id$v=1$m=65536,t=1,p=4$c2FsdA$aGFzaA", // wrong version
} {
if _, err := verifyPassword(bad, "whatever"); err == nil {
t.Errorf("verifyPassword(%q) err = nil, want errBadHash", bad)
}
}
}