Skip to content

Commit ea8f266

Browse files
authored
sec(auth): constant-time token validation via subtle.ConstantTimeCompare (closes #392) (#837)
* sec(auth): constant-time token check after DB fetch (closes #392) SQL equality in PostgreSQL is not constant-time; an attacker on the same VPC can use response-time differences to learn bytes of a SHA-256 hash. The admin API key path already uses subtle.ConstantTimeCompare; align the user API key and password-reset token paths to match. No schema changes: the stored hash format is unchanged. After the DB row is fetched, compare the expected hash against the stored hash via crypto/subtle.ConstantTimeCompare in ValidateUserAPIKey, validateResetToken, and ResetTokenStatus. A mismatch returns the same error as a missing row to avoid timing oracles via differing code paths. Update all test fixtures that set PasswordResetToken to literal stub values to instead use hashSessionToken(token) so they match the hash the service now verifies. Add targeted mismatch test cases to both the API-key and reset-token validation paths. * sec(auth): constant-time session token check (extends #392 PR #837) ValidateSession had the same SQL-equality timing oracle as the user API key and password-reset token paths fixed in this PR: GetSession runs `WHERE token = $1` in PostgreSQL, which short-circuits on first mismatching byte. Session tokens are the hottest auth surface (every authenticated API request hits ValidateSession via handler.go), so the oracle is even more exploitable here than on the API key path. Add a `subtle.ConstantTimeCompare` of `session.Token` against the locally-derived `hashedToken` immediately after the store fetch, returning the same "session not found" error to keep the response indistinguishable from a missing-row outcome. `crypto/subtle` is already imported in service.go. Tests: - New `TestService_ValidateSession/constant-time mismatch rejects session even when DB row exists` asserts the guard fires when the stored token differs from the derived hash. - Fix `internal/server/adapter_test.go` fixtures that used literal stub tokens (`"hashed-token"`, `"hashed-session-token"`) to use a local `hashSessionTokenForTest` helper mirroring auth.hashSessionToken (private), so the new guard passes for the positive cases. `go test ./internal/...` -- 4223 passed, 0 failures. * fix(auth): drop stale Role field from Session test literal Session.Role was removed from the base branch; the constant-time mismatch test in service_test.go still referenced it, breaking go vet. Remove the field from the struct literal; the test logic is unaffected (it verifies token-hash rejection, not role checks). * test(auth): set KeyHash in singleflight fixture for constant-time check ValidateUserAPIKey now runs subtle.ConstantTimeCompare(key.KeyHash, keyHash) after the DB fetch. The TestValidateUserAPIKey_LastUsedSingleflight fixture omitted KeyHash, so the empty stored hash failed the compare and the function returned early before the background UpdateAPIKeyLastUsed ran, breaking the mock's Once expectation. Real DB rows always carry KeyHash, so the fixture must set it to represent the actual validated path.
1 parent fb7b7b1 commit ea8f266

8 files changed

Lines changed: 166 additions & 13 deletions

‎internal/auth/service.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,13 @@ func (s *Service) ValidateSession(ctx context.Context, token string) (*Session,
319319
return nil, fmt.Errorf("session not found")
320320
}
321321

322+
// Constant-time comparison after fetch to close the SQL-equality timing oracle.
323+
// GetSession does `WHERE token = $1` which is not constant-time in PostgreSQL;
324+
// align with ValidateUserAPIKey / validateResetToken (issue #392 PR #837).
325+
if subtle.ConstantTimeCompare([]byte(session.Token), []byte(hashedToken)) != 1 {
326+
return nil, fmt.Errorf("session not found")
327+
}
328+
322329
if session.ExpiresAt.IsZero() {
323330
return nil, fmt.Errorf("session has no expiry: data integrity error")
324331
}

‎internal/auth/service_apikeys.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/rand"
66
"crypto/sha256"
7+
"crypto/subtle"
78
"encoding/base64"
89
"errors"
910
"fmt"
@@ -265,6 +266,13 @@ func (s *Service) ValidateUserAPIKey(ctx context.Context, apiKey string) (*UserA
265266
return nil, nil, fmt.Errorf("invalid API key")
266267
}
267268

269+
// Constant-time comparison after fetch to close the SQL-equality timing oracle.
270+
// The DB lookup already found the row by hash; this redundant check ensures the
271+
// hot path does not leak timing information through early return differences.
272+
if subtle.ConstantTimeCompare([]byte(key.KeyHash), []byte(keyHash)) != 1 {
273+
return nil, nil, fmt.Errorf("invalid API key")
274+
}
275+
268276
err = validateAPIKeyStatus(key)
269277
if err != nil {
270278
return nil, nil, err

‎internal/auth/service_apikeys_test.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,34 @@ func TestService_ValidateUserAPIKey(t *testing.T) {
651651
assert.Nil(t, resultKey)
652652
mockStore.AssertExpectations(t)
653653
})
654+
655+
t.Run("reject when stored hash does not match derived hash", func(t *testing.T) {
656+
// Simulates a DB row whose key_hash field was tampered or
657+
// belongs to a different key -- the constant-time check must reject it.
658+
mockStore := new(MockStore)
659+
service := &Service{store: mockStore}
660+
661+
apiKey := "test-api-key-123456"
662+
hash := sha256.Sum256([]byte(apiKey))
663+
keyHash := base64.RawURLEncoding.EncodeToString(hash[:])
664+
665+
tamperedRecord := &UserAPIKey{
666+
ID: "key-1",
667+
UserID: "user-123",
668+
KeyHash: keyHash + "tampered",
669+
IsActive: true,
670+
}
671+
672+
mockStore.On("GetAPIKeyByHash", ctx, keyHash).Return(tamperedRecord, nil)
673+
674+
resultKey, resultUser, err := service.ValidateUserAPIKey(ctx, apiKey)
675+
676+
assert.Error(t, err)
677+
assert.Contains(t, err.Error(), "invalid API key")
678+
assert.Nil(t, resultUser)
679+
assert.Nil(t, resultKey)
680+
mockStore.AssertExpectations(t)
681+
})
654682
}
655683

656684
func TestService_UpdateLastUsed(t *testing.T) {

‎internal/auth/service_password.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"context"
5+
"crypto/subtle"
56
"errors"
67
"fmt"
78
"strings"
@@ -419,6 +420,11 @@ func (s *Service) ResetTokenStatus(ctx context.Context, token string) (ResetToke
419420
return ResetTokenStateUsed, ResetTokenFlowReset, nil
420421
}
421422

423+
// Constant-time comparison after fetch to close the SQL-equality timing oracle.
424+
if subtle.ConstantTimeCompare([]byte(user.PasswordResetToken), []byte(tokenHash)) != 1 {
425+
return ResetTokenStateUsed, ResetTokenFlowReset, nil
426+
}
427+
422428
if user.PasswordResetExpiry == nil || time.Now().After(*user.PasswordResetExpiry) {
423429
flow := ResetTokenFlowReset
424430
if !user.Active {
@@ -448,6 +454,11 @@ func (s *Service) validateResetToken(ctx context.Context, token string) (*User,
448454
return nil, fmt.Errorf("invalid or expired reset token")
449455
}
450456

457+
// Constant-time comparison after fetch to close the SQL-equality timing oracle.
458+
if subtle.ConstantTimeCompare([]byte(user.PasswordResetToken), []byte(tokenHash)) != 1 {
459+
return nil, fmt.Errorf("invalid or expired reset token")
460+
}
461+
451462
if user.PasswordResetExpiry == nil || time.Now().After(*user.PasswordResetExpiry) {
452463
return nil, fmt.Errorf("reset token has expired")
453464
}

‎internal/auth/service_password_test.go‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
309309
testUser := &User{
310310
ID: "user-123",
311311
Email: "test@example.com",
312-
PasswordResetToken: "hashed-token", // Now stores the hash
312+
PasswordResetToken: hashSessionToken("valid-reset-token"),
313313
PasswordResetExpiry: &expiry,
314314
Active: true,
315315
}
@@ -360,7 +360,7 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
360360
expiredUser := &User{
361361
ID: "user-456",
362362
Email: "expired@example.com",
363-
PasswordResetToken: "hashed-expired-token",
363+
PasswordResetToken: hashSessionToken("expired-reset-token"),
364364
PasswordResetExpiry: &expiry,
365365
Active: true,
366366
}
@@ -389,7 +389,7 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
389389
testUser := &User{
390390
ID: "user-123",
391391
Email: "test@example.com",
392-
PasswordResetToken: "hashed-token",
392+
PasswordResetToken: hashSessionToken("valid-reset-token"),
393393
PasswordResetExpiry: &expiry,
394394
Active: true,
395395
}
@@ -422,7 +422,7 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
422422
testUser := &User{
423423
ID: "user-123",
424424
Email: "test@example.com",
425-
PasswordResetToken: "hashed-token",
425+
PasswordResetToken: hashSessionToken("valid-reset-token"),
426426
PasswordResetExpiry: &expiry,
427427
PasswordHistory: []string{hash},
428428
Active: true,
@@ -462,7 +462,7 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
462462
ID: "user-123",
463463
Email: "test@example.com",
464464
PasswordHash: currentHash,
465-
PasswordResetToken: "hashed-token",
465+
PasswordResetToken: hashSessionToken("valid-reset-token"),
466466
PasswordResetExpiry: &expiry,
467467
Active: true,
468468
}
@@ -483,6 +483,37 @@ func TestService_ConfirmPasswordReset(t *testing.T) {
483483

484484
mockStore.AssertExpectations(t)
485485
})
486+
487+
t.Run("constant-time mismatch rejects token even when DB row exists", func(t *testing.T) {
488+
// The DB returned a user but the stored reset-token hash does not match
489+
// the hash we derived from the supplied token string. The constant-time
490+
// guard must reject the request to avoid timing-based disclosure.
491+
mockStore := new(MockStore)
492+
mockEmail := new(MockEmailSender)
493+
service := createTestService(mockStore, mockEmail)
494+
495+
expiry := time.Now().Add(time.Hour)
496+
mismatchUser := &User{
497+
ID: "user-999",
498+
Email: "mismatch@example.com",
499+
PasswordResetToken: "completely-different-hash",
500+
PasswordResetExpiry: &expiry,
501+
Active: true,
502+
}
503+
504+
mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).Return(mismatchUser, nil).Once()
505+
506+
req := PasswordResetConfirm{
507+
Token: "some-token",
508+
NewPassword: "SecureT3st@789",
509+
}
510+
511+
err := service.ConfirmPasswordReset(ctx, req)
512+
assert.Error(t, err)
513+
assert.Contains(t, err.Error(), "invalid or expired reset token")
514+
515+
mockStore.AssertExpectations(t)
516+
})
486517
}
487518

488519
// TestService_ResetTokenStatus covers the read-only token-status probe
@@ -498,7 +529,7 @@ func TestService_ResetTokenStatus(t *testing.T) {
498529

499530
expiry := time.Now().Add(time.Hour)
500531
mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).
501-
Return(&User{ID: "u1", Active: true, PasswordResetExpiry: &expiry}, nil).Once()
532+
Return(&User{ID: "u1", Active: true, PasswordResetToken: hashSessionToken("valid-token"), PasswordResetExpiry: &expiry}, nil).Once()
502533

503534
state, flow, err := service.ResetTokenStatus(ctx, "valid-token")
504535
require.NoError(t, err)
@@ -515,7 +546,7 @@ func TestService_ResetTokenStatus(t *testing.T) {
515546

516547
expiry := time.Now().Add(time.Hour)
517548
mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).
518-
Return(&User{ID: "u2", Active: false, PasswordResetExpiry: &expiry}, nil).Once()
549+
Return(&User{ID: "u2", Active: false, PasswordResetToken: hashSessionToken("valid-invite-token"), PasswordResetExpiry: &expiry}, nil).Once()
519550

520551
state, flow, err := service.ResetTokenStatus(ctx, "valid-invite-token")
521552
require.NoError(t, err)
@@ -532,7 +563,7 @@ func TestService_ResetTokenStatus(t *testing.T) {
532563

533564
expiry := time.Now().Add(-time.Hour)
534565
mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).
535-
Return(&User{ID: "u3", Active: true, PasswordResetExpiry: &expiry}, nil).Once()
566+
Return(&User{ID: "u3", Active: true, PasswordResetToken: hashSessionToken("expired-token"), PasswordResetExpiry: &expiry}, nil).Once()
536567

537568
state, flow, err := service.ResetTokenStatus(ctx, "expired-token")
538569
require.NoError(t, err)
@@ -589,6 +620,32 @@ func TestService_ResetTokenStatus(t *testing.T) {
589620

590621
mockStore.AssertExpectations(t)
591622
})
623+
624+
t.Run("constant-time mismatch returns used even if DB row exists", func(t *testing.T) {
625+
// The DB returned a row but the stored token hash does not match the
626+
// hash derived from the supplied token. The constant-time check must
627+
// treat this as a missing/consumed token rather than branching on
628+
// expiry state, to avoid leaking information through timing.
629+
mockStore := new(MockStore)
630+
mockEmail := new(MockEmailSender)
631+
service := createTestService(mockStore, mockEmail)
632+
633+
expiry := time.Now().Add(time.Hour)
634+
mockStore.On("GetUserByResetToken", ctx, mock.AnythingOfType("string")).
635+
Return(&User{
636+
ID: "u9",
637+
Active: true,
638+
PasswordResetToken: "different-hash-entirely",
639+
PasswordResetExpiry: &expiry,
640+
}, nil).Once()
641+
642+
state, flow, err := service.ResetTokenStatus(ctx, "some-token")
643+
require.NoError(t, err)
644+
assert.Equal(t, ResetTokenStateUsed, state)
645+
assert.Equal(t, ResetTokenFlowReset, flow)
646+
647+
mockStore.AssertExpectations(t)
648+
})
592649
}
593650

594651
// Test password validation rules.

‎internal/auth/service_security_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ func TestValidateUserAPIKey_LastUsedSingleflight(t *testing.T) {
244244
ID: keyID,
245245
UserID: user.ID,
246246
IsActive: true,
247+
KeyHash: keyHash,
247248
KeyPrefix: rawKey[:8],
248249
}
249250

‎internal/auth/service_test.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,34 @@ func TestService_ValidateSession(t *testing.T) {
190190

191191
mockStore.AssertExpectations(t)
192192
})
193+
194+
t.Run("constant-time mismatch rejects session even when DB row exists", func(t *testing.T) {
195+
// The DB returned a session row but its stored token hash does not
196+
// match the hash we derived from the supplied token string. The
197+
// constant-time guard (added alongside the API-key and reset-token
198+
// equivalents in PR #837) must reject the request to avoid leaking
199+
// timing information through the SQL equality oracle.
200+
mockStore := new(MockStore)
201+
mockEmail := new(MockEmailSender)
202+
service := createTestService(mockStore, mockEmail)
203+
204+
hashedToken := hashSessionToken("some-token")
205+
mismatchSession := &Session{
206+
Token: "completely-different-hash",
207+
UserID: "user-999",
208+
Email: "mismatch@example.com",
209+
ExpiresAt: time.Now().Add(time.Hour),
210+
}
211+
212+
mockStore.On("GetSession", ctx, hashedToken).Return(mismatchSession, nil).Once()
213+
214+
session, err := service.ValidateSession(ctx, "some-token")
215+
assert.Error(t, err)
216+
assert.Nil(t, session)
217+
assert.Contains(t, err.Error(), "session not found")
218+
219+
mockStore.AssertExpectations(t)
220+
})
193221
}
194222

195223
func TestService_Logout(t *testing.T) {
@@ -631,7 +659,7 @@ func TestService_ErrorPaths(t *testing.T) {
631659
testUser := &User{
632660
ID: "user-123",
633661
Email: "test@example.com",
634-
PasswordResetToken: "hashed-token",
662+
PasswordResetToken: hashSessionToken("valid-reset-token"),
635663
PasswordResetExpiry: &expiry,
636664
Active: true,
637665
}

‎internal/server/adapter_test.go‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package server
22

33
import (
44
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"testing"
68
"time"
79

@@ -12,6 +14,15 @@ import (
1214
"github.com/stretchr/testify/require"
1315
)
1416

17+
// hashSessionTokenForTest mirrors auth.hashSessionToken (private). Session
18+
// tokens are stored as sha256+hex so the constant-time guard in
19+
// auth.Service.ValidateSession matches when both sides are derived from the
20+
// same raw token.
21+
func hashSessionTokenForTest(token string) string {
22+
h := sha256.Sum256([]byte(token))
23+
return hex.EncodeToString(h[:])
24+
}
25+
1526
func createMockAuthService(t *testing.T) (*authServiceAdapter, *auth.MockStore) {
1627
t.Helper()
1728
mockStore := new(auth.MockStore)
@@ -62,9 +73,10 @@ func TestAuthServiceAdapter_ValidateSession(t *testing.T) {
6273
adapter, mockStore := createMockAuthService(t)
6374
ctx := context.Background()
6475

65-
// Token gets hashed, use mock.Anything
76+
// Token gets hashed; the stored Token must match hashSessionToken("valid-token")
77+
// so the constant-time guard in ValidateSession (PR #837 follow-up) succeeds.
6678
mockStore.On("GetSession", ctx, mock.AnythingOfType("string")).Return(&auth.Session{
67-
Token: "hashed-token",
79+
Token: hashSessionTokenForTest("valid-token"),
6880
UserID: "user-1",
6981
Email: "test@example.com",
7082
ExpiresAt: time.Now().Add(time.Hour),
@@ -235,9 +247,10 @@ func TestAuthServiceAdapter_ValidateCSRFToken(t *testing.T) {
235247
adapter, mockStore := createMockAuthService(t)
236248
ctx := context.Background()
237249

238-
// Token gets hashed, so use mock.Anything
250+
// Token gets hashed; the stored Token must match hashSessionToken("session-token")
251+
// so the constant-time guard in ValidateSession (PR #837 follow-up) succeeds.
239252
mockStore.On("GetSession", ctx, mock.AnythingOfType("string")).Return(&auth.Session{
240-
Token: "hashed-session-token",
253+
Token: hashSessionTokenForTest("session-token"),
241254
UserID: "user-1",
242255
CSRFToken: "csrf-token",
243256
ExpiresAt: time.Now().Add(time.Hour),

0 commit comments

Comments
 (0)